Compare commits
637 Commits
v0.0.1
...
graphql-re
| Author | SHA1 | Date | |
|---|---|---|---|
| ea66419f33 | |||
| dc014a08ce | |||
| 90d6562f45 | |||
| 3563fef461 | |||
| 2968987c6b | |||
| 924ffae07e | |||
| 80f0ced0b0 | |||
|
|
0def39cd73 | ||
|
|
1c60837ec9 | ||
|
|
d42a700cec | ||
|
|
40fd8ede5d | ||
|
|
24b09c9c5e | ||
| c176dab78b | |||
| c0b189c9e6 | |||
| f3333b5c2c | |||
| 2ce90d94c0 | |||
| 0e445ba3d4 | |||
| 9c5fb93d5b | |||
| 338a46224d | |||
|
|
0615d08e7d | ||
| 21a127509b | |||
|
|
e92b86792e | ||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 | |||
|
|
f57bd35cd4 | ||
|
|
56c9fb03ac | ||
|
|
26c9c1e562 | ||
|
|
51e18c65d1 | ||
|
|
b82d5fd350 | ||
| 9a3ccba719 | |||
|
|
c89e4af328 | ||
| df4cecedf0 | |||
| 3dee53f33f | |||
| ef05dee600 | |||
| 2b6bce4731 | |||
| fc4c5c61e2 | |||
| ae04260f69 | |||
|
|
ad5fc0b8b3 | ||
| c03f706e9d | |||
| 4f49e538a8 | |||
|
|
68442894d0 | ||
| 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 = {
|
module.exports = {
|
||||||
extends: [
|
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
||||||
"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
|
|
||||||
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
|
||||||
"plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors.
|
|
||||||
],
|
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
jsx: true, // Allows for the parsing of JSX
|
jsx: true // Allows for the parsing of JSX
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
|
plugins: ["@typescript-eslint", "css-modules"],
|
||||||
settings: {
|
settings: {
|
||||||
"import/resolver": {
|
"import/resolver": {
|
||||||
node: {
|
node: {
|
||||||
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
extensions: [".js", ".jsx", ".ts", ".tsx"]
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
react: {
|
react: {
|
||||||
version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use
|
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fine tune rules
|
// Fine tune rules
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-var-requires": 0,
|
"@typescript-eslint/no-var-requires": 0
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
20
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- name: Publish to Registry
|
||||||
|
uses: elgohr/Publish-Docker-Github-Action@v5
|
||||||
|
with:
|
||||||
|
name: frishi/threetwo
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
11
.gitignore
vendored
@@ -4,10 +4,19 @@ comics/
|
|||||||
docs/
|
docs/
|
||||||
userdata/
|
userdata/
|
||||||
dist/
|
dist/
|
||||||
|
storybook-static/*
|
||||||
src/client/assets/scss/App.css
|
src/client/assets/scss/App.css
|
||||||
server/
|
/server/
|
||||||
node_modules/
|
node_modules/
|
||||||
src/**/*.jsx
|
src/**/*.jsx
|
||||||
tests/__coverage__/
|
tests/__coverage__/
|
||||||
tests/**/*.jsx
|
tests/**/*.jsx
|
||||||
src/client/assets/scss/App.css.map
|
src/client/assets/scss/App.css.map
|
||||||
|
yarn-error.log
|
||||||
|
.nova
|
||||||
|
environment.list
|
||||||
|
.env
|
||||||
|
src/client/assets/img/missing-file.pxd
|
||||||
|
*.pxd
|
||||||
|
.parcel-cache
|
||||||
|
src/stories
|
||||||
|
|||||||
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
|
|
||||||
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Use Node.js 22 as the base image
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /threetwo
|
||||||
|
|
||||||
|
# Copy package.json and yarn.lock to leverage Docker cache
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
# Install build dependencies necessary for native modules (for node-sass)
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
nasm \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Install node modules
|
||||||
|
RUN yarn install --ignore-engines
|
||||||
|
|
||||||
|
# Explicitly install sass
|
||||||
|
RUN yarn add -D sass
|
||||||
|
|
||||||
|
# Copy the rest of the application files into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose the application port (default for Vite)
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Start the application with yarn
|
||||||
|
ENTRYPOINT ["yarn", "start"]
|
||||||
72
README.md
@@ -0,0 +1,72 @@
|
|||||||
|
# ThreeTwo!
|
||||||
|
|
||||||
|
ThreeTwo! _aims to be_ a comic book curation app.
|
||||||
|
|
||||||
|
[](https://github.com/rishighan/threetwo/actions/workflows/docker-image.yml)
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
#### Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Issue View
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### DC++ Search
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Import
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Comic Vine Matching, Metadata Scraping
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 🦄 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-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
|
||||||
|
5. [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)
|
||||||
|
|||||||
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 },
|
"tags": {
|
||||||
"source": {
|
"allowUnknownTags": false
|
||||||
"include": ["./src/"],
|
},
|
||||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
"source": {
|
||||||
},
|
"include": [
|
||||||
"plugins": [
|
"./src/client"
|
||||||
"better-docs/component",
|
],
|
||||||
"better-docs/category",
|
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||||
"plugins/markdown",
|
},
|
||||||
"node_modules/better-docs/typescript"
|
"plugins": [
|
||||||
],
|
"plugins/markdown"
|
||||||
"templates": { "better-docs": { "name": "My React components" } },
|
],
|
||||||
"opts": {
|
"opts": {
|
||||||
"destination": "docs/",
|
"template": "node_modules/tui-jsdoc-template",
|
||||||
"readme": "README.md",
|
"encoding": "utf8",
|
||||||
"recurse": true,
|
"destination": "docs/",
|
||||||
"encoding": "utf8",
|
"recurse": true,
|
||||||
"verbose": true,
|
"verbose": true
|
||||||
"template": "node_modules/better-docs"
|
},
|
||||||
}
|
"templates": {
|
||||||
}
|
"cleverLinks": false,
|
||||||
|
"monospaceLinks": false
|
||||||
|
}
|
||||||
|
}
|
||||||
226
package.json
@@ -1,150 +1,136 @@
|
|||||||
{
|
{
|
||||||
"name": "threetwo",
|
"name": "threetwo",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"description": "ThreeTwo! A comic book curator.",
|
"description": "ThreeTwo! A good comic book curator.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"typings": "server/index.js",
|
"typings": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "vite build",
|
||||||
"start": "npm run build && npm run server",
|
"dev": "rimraf dist && npm run build && vite",
|
||||||
"client": "webpack serve --mode development --devtool inline-source-map --hot",
|
"start": "npm run build && vite",
|
||||||
"server": "tsc -p tsconfig.server.json && node server/",
|
"docs": "jsdoc -c jsdoc.json",
|
||||||
"dev": "concurrently \"nodemon\" \"npm run client\"",
|
"storybook": "storybook dev -p 6006",
|
||||||
"server-dev": "nodemon",
|
"build-storybook": "storybook build"
|
||||||
"docs": "jsdoc -c jsdoc.json"
|
|
||||||
},
|
},
|
||||||
"author": "Rishi Ghan",
|
"author": "Rishi Ghan",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.13.17",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@bluelovers/fast-glob": "^3.0.4",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@types/event-stream": "^3.3.34",
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
|
"@floating-ui/react": "^0.26.12",
|
||||||
|
"@floating-ui/react-dom": "^2.0.8",
|
||||||
|
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@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/mime-types": "^2.1.0",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/react-dom": "^17.0.2",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@types/react-redux": "^7.1.16",
|
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"axios": "^1.8.2",
|
||||||
"@types/sharp": "^0.28.0",
|
"axios-cache-interceptor": "^1.0.1",
|
||||||
"@types/socket.io": "^3.0.2",
|
"axios-rate-limit": "^1.3.0",
|
||||||
"@types/socket.io-client": "^3.0.0",
|
"babel-plugin-styled-components": "^2.1.4",
|
||||||
"@types/through2": "^2.0.36",
|
"date-fns": "^2.28.0",
|
||||||
"airdcpp-apisocket": "^2.4.1",
|
"dayjs": "^1.10.6",
|
||||||
"antd": "^4.16.5",
|
"ellipsize": "^0.5.1",
|
||||||
"babel-polyfill": "^6.26.0",
|
"express": "^4.20.0",
|
||||||
"better-docs": "^2.3.2",
|
"filename-parser": "^1.0.2",
|
||||||
"calibre-opds": "^1.0.7",
|
|
||||||
"comlink-loader": "^2.0.0",
|
|
||||||
"ellipsize": "^0.1.0",
|
|
||||||
"event-stream": "^4.0.1",
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"fastest-validator": "^1.11.0",
|
|
||||||
"final-form": "^4.20.2",
|
"final-form": "^4.20.2",
|
||||||
"fs-extra": "^9.1.0",
|
"final-form-arrays": "^3.0.2",
|
||||||
"http-response-stream": "^1.0.7",
|
"focus-trap-react": "^10.2.3",
|
||||||
"imghash": "^0.0.8",
|
"graphql": "^16.0.0",
|
||||||
"jsdoc": "^3.6.7",
|
"graphql-request": "^7.2.0",
|
||||||
"opds-extra": "^3.0.9",
|
"history": "^5.3.0",
|
||||||
|
"html-to-text": "^8.1.0",
|
||||||
|
"i18next": "^23.11.1",
|
||||||
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
|
"i18next-http-backend": "^2.5.0",
|
||||||
|
"immer": "^10.0.3",
|
||||||
|
"jsdoc": "^3.6.10",
|
||||||
|
"keen-slider": "^6.8.6",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"react": "^17.0.1",
|
"prop-types": "^15.8.1",
|
||||||
"react-collapsible": "^2.8.3",
|
"qs": "^6.10.5",
|
||||||
"react-dom": "^17.0.1",
|
"react": "^18.3.1",
|
||||||
"react-final-form": "^6.5.3",
|
"react-collapsible": "^2.9.0",
|
||||||
"react-spinners": "^0.11.0",
|
"react-comic-viewer": "^0.4.0",
|
||||||
"react-window-dynamic-list": "^2.3.5",
|
"react-day-picker": "^8.10.0",
|
||||||
"sharp": "^0.28.1",
|
"react-dom": "^18.2.0",
|
||||||
"socket.io-client": "^4.1.2",
|
"react-fast-compare": "^3.2.0",
|
||||||
"threetwo-ui-typings": "^1.0.1",
|
"react-final-form": "^6.5.9",
|
||||||
"voca": "^1.4.0",
|
"react-final-form-arrays": "^3.1.4",
|
||||||
|
"react-i18next": "^14.1.0",
|
||||||
|
"react-loader-spinner": "^4.0.0",
|
||||||
|
"react-modal": "^3.15.1",
|
||||||
|
"react-router": "^7.5.2",
|
||||||
|
"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-textarea-autosize": "^8.3.4",
|
||||||
|
"react-toastify": "^10.0.5",
|
||||||
|
"socket.io-client": "^4.3.2",
|
||||||
|
"styled-components": "^6.1.0",
|
||||||
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
|
"vite": "^5.4.19",
|
||||||
|
"vite-plugin-html": "^3.2.0",
|
||||||
"websocket": "^1.0.34",
|
"websocket": "^1.0.34",
|
||||||
"ws": "^7.5.3",
|
"zustand": "^4.4.6"
|
||||||
"ws-calibre": "bluelovers/ws-calibre",
|
|
||||||
"xregexp": "^5.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.13.10",
|
"@iconify-json/solar": "^1.1.8",
|
||||||
"@babel/core": "^7.13.10",
|
"@iconify/tailwind": "^0.1.4",
|
||||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
"@storybook/addon-essentials": "^7.4.1",
|
||||||
"@babel/preset-env": "^7.13.10",
|
"@storybook/addon-interactions": "^7.4.1",
|
||||||
"@babel/preset-react": "^7.12.13",
|
"@storybook/addon-links": "^7.4.1",
|
||||||
"@babel/preset-typescript": "^7.13.0",
|
"@storybook/addon-onboarding": "^1.0.8",
|
||||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
"@storybook/blocks": "^7.4.1",
|
||||||
"@root/walk": "^1.1.0",
|
"@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",
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@types/ellipsize": "^0.1.1",
|
||||||
"@types/express": "^4.17.8",
|
"@types/express": "^4.17.8",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/lodash": "^4.14.168",
|
"@types/lodash": "^4.14.168",
|
||||||
"@types/mongoose": "^5.7.37",
|
|
||||||
"@types/node": "^14.14.34",
|
"@types/node": "^14.14.34",
|
||||||
"@types/pino": "^6.3.7",
|
"@types/react": "^18.0.28",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@types/react-dom": "^17.0.2",
|
"@types/react-redux": "^7.1.25",
|
||||||
"@types/react-redux": "^7.1.16",
|
"autoprefixer": "^10.4.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",
|
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"buffer": "^6.0.3",
|
"docdash": "^2.0.2",
|
||||||
"bulma": "^0.9.3",
|
"eslint": "^8.49.0",
|
||||||
"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",
|
|
||||||
"eslint-config-prettier": "^8.1.0",
|
"eslint-config-prettier": "^8.1.0",
|
||||||
|
"eslint-plugin-css-modules": "^2.11.0",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
|
"eslint-plugin-jsdoc": "^46.6.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
"etl": "^0.6.12",
|
"eslint-plugin-storybook": "^0.6.13",
|
||||||
"express": "^4.17.1",
|
"express": "^4.20.0",
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"html-webpack-plugin": "^5.3.1",
|
|
||||||
"image-webpack-loader": "^7.0.1",
|
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^29.6.3",
|
||||||
"lodash": "^4.17.21",
|
"nodemon": "^3.0.1",
|
||||||
"mini-css-extract-plugin": "^1.4.1",
|
"postcss": "^8.4.32",
|
||||||
"mongoose": "^5.10.11",
|
"postcss-import": "^15.1.0",
|
||||||
"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",
|
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"qs": "^6.10.1",
|
"react-refresh": "^0.14.0",
|
||||||
"react": "^17.0.1",
|
"rimraf": "^4.1.3",
|
||||||
"react-dom": "^17.0.1",
|
"sass": "^1.77.0",
|
||||||
"react-hot-loader": "^4.13.0",
|
"storybook": "^7.3.2",
|
||||||
"react-redux": "^7.2.3",
|
"tailwindcss": "^3.4.1",
|
||||||
"react-router": "^5.2.0",
|
"tui-jsdoc-template": "^1.2.2",
|
||||||
"react-router-dom": "^5.2.0",
|
"typescript": "^5.1.6"
|
||||||
"redux-thunk": "^2.3.0",
|
},
|
||||||
"rimraf": "^3.0.2",
|
"resolutions": {
|
||||||
"sass-loader": "^11.0.1",
|
"jackspeak": "2.1.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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
|
||||||
BIN
screenshots/CVMatching.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
screenshots/ComicDetail.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
screenshots/DC++Searching.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
screenshots/Dashboard.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/Import.jpg
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
screenshots/Library.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
@@ -1,159 +1,17 @@
|
|||||||
# Client side boilerplate with ReactJS library and Typescript
|
## ThreeTwo UI
|
||||||
|
|
||||||
## Introduction
|
##### I have tried my best to document the project through folder organization, comments, and actual JSDocs where applicable. Unit tests and I have not agreed for a long time now, and I think it won't change anytime soon.
|
||||||
|
|
||||||
In the client side boilerplate, Typescript has been used to achieve a more structured and maintainable source code. ReactJS library which is one of the most important libraries for UI development alongside the other big names in the market, has been picked over to build the presentation layer of the application. Also for CSS, Less has been used to make CSS more functional.
|
|
||||||
|
|
||||||
### Less
|
This folder houses all the components, utils and libraries that make up ThreeTwo's UI
|
||||||
|
|
||||||
[Less](http://lesscss.org/) is a backwards-compatible language extension for CSS. Less helps to write CSS in a functional way and It's really easy to read and understand.
|
It is based on React 18, and uses:
|
||||||
|
|
||||||
### ESLint
|
1. _Redux_ for state management
|
||||||
|
2. _socket.io_ for transferring data in real-time
|
||||||
|
3. _React Router_ for routing
|
||||||
|
4. React DnD for drag-and-drop
|
||||||
|
5. @tanstack/react-table for all tables
|
||||||
|
|
||||||
[ESLint](https://eslint.org/) is a pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript and Typescript.
|
|
||||||
|
|
||||||
[.eslintrc.json file](<(https://eslint.org/docs/user-guide/configuring)>) (alternatively configurations can be written in Javascript or YAML as well) is used describe the configurations required for ESLint. Below is the .eslintrc.json file which has been used.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
"extends": ["airbnb"],
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-console": "off",
|
|
||||||
"comma-dangle": "off",
|
|
||||||
"react/jsx-filename-extension": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
[Airbnb's Javascript Style Guide](https://github.com/airbnb/javascript) which has been used by the majority of JavaScript and Typescript developers worldwide. Since the aim is support for both client (browser) and server side (Node.js) source code, the **env** has been set to browser and node.
|
|
||||||
Optionally, you can override the current settings by installing `eslint` globally and running `eslint --init` to change the configurations to suit your needs. [**no-console**](https://eslint.org/docs/rules/no-console), [**comma-dangle**](https://eslint.org/docs/rules/comma-dangle) and [**react/jsx-filename-extension**](https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md) rules have been turned off.
|
|
||||||
|
|
||||||
### Webpack
|
|
||||||
|
|
||||||
[Webpack](https://webpack.js.org/) is a module bundler. Its main purpose is to capable Front-end developers to experience a modular programming style and bundle JavaScript and CSS files for usage in a browser.
|
|
||||||
|
|
||||||
[webpack.config.js](https://webpack.js.org/configuration/) file has been used to describe the configurations required for webpack. Below is the webpack.config.js file which has been used.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const path = require('path');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
const CopyPlugin = require('copy-webpack-plugin');
|
|
||||||
|
|
||||||
const outputDirectory = 'dist';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
entry: ['babel-polyfill', './src/client/index.tsx'],
|
|
||||||
output: {
|
|
||||||
path: path.join(__dirname, outputDirectory),
|
|
||||||
filename: './js/[name].bundle.js'
|
|
||||||
},
|
|
||||||
devtool: "source-map",
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.(js|jsx)$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: {
|
|
||||||
loader: 'babel-loader'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.tsx?$/,
|
|
||||||
use:[
|
|
||||||
{
|
|
||||||
loader: "awesome-typescript-loader"
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exclude: /node_modules/
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enforce: "pre",
|
|
||||||
test: /\.js$/,
|
|
||||||
loader: "source-map-loader"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.less$/,
|
|
||||||
use: [
|
|
||||||
{ loader: 'style-loader' },
|
|
||||||
{
|
|
||||||
loader: MiniCssExtractPlugin.loader,
|
|
||||||
options: {
|
|
||||||
publicPath: './Less',
|
|
||||||
hmr: process.env.NODE_ENV === 'development',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ loader: 'css-loader' },
|
|
||||||
{
|
|
||||||
loader: 'less-loader',
|
|
||||||
options: {
|
|
||||||
strictMath: true,
|
|
||||||
noIeCompat: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(png|woff|woff2|eot|ttf|svg)$/,
|
|
||||||
loader: 'url-loader?limit=100000'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: ['*', '.ts', '.tsx', '.js', '.jsx', '.json', '.less']
|
|
||||||
},
|
|
||||||
devServer: {
|
|
||||||
port: 3000,
|
|
||||||
open: true,
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8050'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new CleanWebpackPlugin([outputDirectory]),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
template: './public/index.html',
|
|
||||||
favicon: './public/favicon.ico',
|
|
||||||
title: "Book Manager",
|
|
||||||
}),
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
filename: './css/[name].css',
|
|
||||||
chunkFilename: './css/[id].css',
|
|
||||||
}),
|
|
||||||
new CopyPlugin([
|
|
||||||
{ from: './src/client/Assets', to: 'assets' },
|
|
||||||
])
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **entry:** entry: ./src/client/index.tsx is where the application starts executing and Webpack starts bundling.
|
|
||||||
Note: babel-polyfill is added to support async/await. Read more [here](https://babeljs.io/docs/en/babel-polyfill#usage-in-node-browserify-webpack).
|
|
||||||
2. **output path and filename:** the target directory and the filename for the bundled output.
|
|
||||||
3. **module loaders:** Module loaders are transformations that are applied on the source code of a module. We pass all the js file through [babel-loader](https://github.com/babel/babel-loader) to transform JSX to Javascript. CSS files are passed through [css-loaders](https://github.com/webpack-contrib/css-loader) and [style-loaders](https://github.com/webpack-contrib/style-loader) to load and bundle CSS files. Fonts and images are loaded through url-loader.
|
|
||||||
4. **Dev Server:** Configurations for the webpack-dev-server which will be described in coming section.
|
|
||||||
5. **plugins:** [clean-webpack-plugin](https://github.com/johnagan/clean-webpack-plugin) is a webpack plugin to remove the build directory before building. [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) simplifies creation of HTML files to serve your webpack bundles. It loads the template (public/index.html) and injects the output bundle.
|
|
||||||
|
|
||||||
### Webpack dev server
|
|
||||||
|
|
||||||
[Webpack dev server](https://webpack.js.org/configuration/dev-server/) is used along with webpack. It provides a development server that enables live reloading for the client side code changes.
|
|
||||||
|
|
||||||
The devServer section of webpack.config.js contains the configuration required to run webpack-dev-server which is given below.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
devServer: {
|
|
||||||
port: 3000,
|
|
||||||
open: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:8050"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
[**Port**](https://webpack.js.org/configuration/dev-server/#devserver-port) specifies the Webpack dev server to listen on this particular port (3000 in this case). When [**open**](https://webpack.js.org/configuration/dev-server/#devserver-open) is set to true, it will automatically open the home page on start-up. [Proxying](https://webpack.js.org/configuration/dev-server/#devserver-proxy) URLs can be useful when you have a separate API backend development server, and you want to send API requests on the same domain.
|
|
||||||
|
|||||||
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 axios from "axios";
|
||||||
import rateLimiter from "axios-rate-limit";
|
import rateLimiter from "axios-rate-limit";
|
||||||
import qs from "qs";
|
import { setupCache } from "axios-cache-interceptor";
|
||||||
import {
|
import {
|
||||||
CV_SEARCH_SUCCESS,
|
CV_SEARCH_SUCCESS,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
CV_API_GENERIC_FAILURE,
|
CV_API_GENERIC_FAILURE,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
|
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
|
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
|
CV_CLEANUP,
|
||||||
|
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||||
|
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||||
|
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
|
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
|
CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
|
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||||
|
LIBRARY_STATISTICS_FETCHED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
import { COMICBOOKINFO_SERVICE_URI } from "../constants/endpoints";
|
import {
|
||||||
|
COMICVINE_SERVICE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
} from "../constants/endpoints";
|
||||||
|
|
||||||
const http = rateLimiter(axios.create(), {
|
const http = rateLimiter(axios.create(), {
|
||||||
maxRequests: 1,
|
maxRequests: 1,
|
||||||
perMilliseconds: 1000,
|
perMilliseconds: 1000,
|
||||||
maxRPS: 1,
|
maxRPS: 1,
|
||||||
});
|
});
|
||||||
|
const cachedAxios = setupCache(axios);
|
||||||
|
export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||||
|
method: "get",
|
||||||
|
params: options,
|
||||||
|
}).then((response) => {
|
||||||
|
dispatch({
|
||||||
|
type: CV_WEEKLY_PULLLIST_FETCHED,
|
||||||
|
data: response.data.result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const comicinfoAPICall = (options) => async (dispatch) => {
|
export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,7 +53,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
inProgress: true,
|
inProgress: true,
|
||||||
});
|
});
|
||||||
const serviceURI = COMICBOOKINFO_SERVICE_URI + options.callURIAction;
|
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
|
||||||
const response = await http(serviceURI, {
|
const response = await http(serviceURI, {
|
||||||
method: options.callMethod,
|
method: options.callMethod,
|
||||||
params: options.callParams,
|
params: options.callParams,
|
||||||
@@ -29,16 +62,13 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
},
|
},
|
||||||
paramsSerializer: (params) => {
|
|
||||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (options.callURIAction) {
|
switch (options.callURIAction) {
|
||||||
case "search":
|
case "search":
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_SEARCH_SUCCESS,
|
type: CV_SEARCH_SUCCESS,
|
||||||
result: response.data,
|
searchResults: response.data,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -53,3 +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 axios from "axios";
|
||||||
import { IFolderData, IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
import { IFolderData } from "threetwo-ui-typings";
|
||||||
import { API_BASE_URI, SOCKET_BASE_URI } from "../constants/endpoints";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import {
|
import {
|
||||||
IMS_COMICBOOK_METADATA_FETCHED,
|
COMICVINE_SERVICE_URI,
|
||||||
IMS_SOCKET_CONNECTION_CONNECTED,
|
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
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_RECENT_COMICS_FETCHED,
|
||||||
|
IMS_WANTED_COMICS_FETCHED,
|
||||||
CV_API_CALL_IN_PROGRESS,
|
CV_API_CALL_IN_PROGRESS,
|
||||||
CV_SEARCH_SUCCESS,
|
CV_SEARCH_SUCCESS,
|
||||||
CV_CLEANUP,
|
CV_CLEANUP,
|
||||||
|
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
|
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
LS_IMPORT,
|
||||||
|
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
|
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
|
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_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";
|
} from "../constants/action-types";
|
||||||
import { refineQuery } from "../shared/utils/filenameparser.utils";
|
import { success } from "react-notification-system-redux";
|
||||||
import { matchScorer } from "../shared/utils/searchmatchscorer.utils";
|
|
||||||
|
|
||||||
|
import { isNil } 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>> {
|
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||||
return axios
|
return axios
|
||||||
.request<Array<IFolderData>>({
|
.request<Array<IFolderData>>({
|
||||||
url: "http://localhost:3000/api/import/walkFolders",
|
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: {
|
||||||
basePathToWalk: path,
|
basePathToWalk: path,
|
||||||
@@ -31,131 +71,315 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Fetches comic book covers along with some metadata
|
* Fetches comic book covers along with some metadata
|
||||||
*
|
* @return the comic book metadata
|
||||||
* using {@link Renderer}.
|
|
||||||
*
|
|
||||||
* Used by external plugins
|
|
||||||
*
|
|
||||||
* @param {Object} options
|
|
||||||
* @return {Promise<string>} HTML of the page
|
|
||||||
*/
|
*/
|
||||||
export const fetchComicBookMetadata = (options) => async (dispatch) => {
|
export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||||
const extractionOptions = {
|
dispatch({
|
||||||
sourceFolder: options,
|
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
extractTarget: "cover",
|
|
||||||
targetExtractionFolder: "./userdata/covers",
|
|
||||||
extractionMode: "bulk",
|
|
||||||
paginationOptions: {
|
|
||||||
pageLimit: 25,
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const walkedFolders = await walkFolder("./comics");
|
|
||||||
|
|
||||||
const socket = io(SOCKET_BASE_URI, {
|
|
||||||
reconnectionDelayMax: 10000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("connect", () => {
|
// dispatch(
|
||||||
console.log(`connect ${socket.id}`);
|
// success({
|
||||||
dispatch({
|
// // uid: 'once-please', // you can specify your own uid if required
|
||||||
type: IMS_SOCKET_CONNECTION_CONNECTED,
|
// title: "Import Started",
|
||||||
socketConnected: true,
|
// message: `<span class="icon-text has-text-success"><i class="fas fa-plug"></i></span> Socket <span class="has-text-info">${socket.id}</span> connected. <strong>${walkedFolders.length}</strong> comics scanned.`,
|
||||||
});
|
// dismissible: "click",
|
||||||
|
// position: "tr",
|
||||||
|
// autoDismiss: 0,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
dispatch({
|
||||||
|
type: LS_IMPORT,
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
await axios.request({
|
||||||
console.log(`disconnect`);
|
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
|
||||||
});
|
method: "POST",
|
||||||
socket.emit("importComicsInDB", {
|
data: { sessionId },
|
||||||
action: "getComicCovers",
|
|
||||||
params: {
|
|
||||||
extractionOptions,
|
|
||||||
walkedFolders,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("comicBookCoverMetadata", (data: IExtractedComicBookCoverFile) => {
|
|
||||||
dispatch({
|
|
||||||
type: IMS_COMICBOOK_METADATA_FETCHED,
|
|
||||||
data,
|
|
||||||
dataTransferred: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRecentlyImportedComicBooks = (options) => async (dispatch) => {
|
export const getImportJobResultStatistics = () => async (dispatch) => {
|
||||||
const { paginationOptions } = options;
|
const result = await axios.request({
|
||||||
return axios
|
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
|
||||||
.request({
|
method: "GET",
|
||||||
url: "http://localhost:3000/api/import/getRecentlyImportedComicBooks",
|
});
|
||||||
method: "POST",
|
dispatch({
|
||||||
data: {
|
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||||
paginationOptions,
|
data: result.data,
|
||||||
},
|
});
|
||||||
})
|
};
|
||||||
.then((response) => {
|
|
||||||
|
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({
|
dispatch({
|
||||||
type: IMS_RECENT_COMICS_FETCHED,
|
type: IMS_RECENT_COMICS_FETCHED,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
});
|
});
|
||||||
});
|
break;
|
||||||
|
case "wanted":
|
||||||
|
dispatch({
|
||||||
|
type: IMS_WANTED_COMICS_FETCHED,
|
||||||
|
data: response.data.docs,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("Unrecognized comic status.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchComicVineMatches = (searchPayload) => (dispatch) => {
|
/**
|
||||||
try {
|
* Makes a call to library service to import the comic book metadata into the ThreeTwo data store.
|
||||||
const issueString = searchPayload.rawFileDetails.path.split("/").pop();
|
* @returns Nothing.
|
||||||
let seriesSearchQuery = {};
|
* @param payload
|
||||||
const issueSearchQuery = refineQuery(issueString);
|
*/
|
||||||
if (searchPayload.rawFileDetails.containedIn !== "comics") {
|
export const importToDB =
|
||||||
seriesSearchQuery = refineQuery(
|
(sourceName: string, metadata?: any) => (dispatch) => {
|
||||||
searchPayload.rawFileDetails.containedIn.split("/").pop(),
|
try {
|
||||||
);
|
const comicBookMetadata = {
|
||||||
}
|
importType: "new",
|
||||||
|
payload: {
|
||||||
dispatch({
|
rawFileDetails: {
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
name: "",
|
||||||
});
|
},
|
||||||
|
importStatus: {
|
||||||
axios
|
isImported: true,
|
||||||
.request({
|
tagged: false,
|
||||||
url: "http://localhost:3080/api/comicvine/fetchseries",
|
matchedResult: {
|
||||||
method: "POST",
|
score: "0",
|
||||||
data: {
|
},
|
||||||
format: "json",
|
},
|
||||||
sort: "name%3Aasc",
|
sourcedMetadata: metadata || null,
|
||||||
query: issueSearchQuery.searchParams.searchTerms.name,
|
acquisition: { source: { wanted: true, name: sourceName } },
|
||||||
fieldList: "id",
|
|
||||||
limit: "10",
|
|
||||||
offset: "0",
|
|
||||||
resources: "issue",
|
|
||||||
},
|
},
|
||||||
transformResponse: [
|
};
|
||||||
(r) => {
|
dispatch({
|
||||||
const searchMatches = JSON.parse(r);
|
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||||
return matchScorer(searchMatches.results, {
|
|
||||||
issue: issueSearchQuery,
|
|
||||||
series: seriesSearchQuery,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
dispatch({
|
|
||||||
type: CV_SEARCH_SUCCESS,
|
|
||||||
searchResults: response.data,
|
|
||||||
searchQueryObject: {
|
|
||||||
issue: issueSearchQuery,
|
|
||||||
series: seriesSearchQuery,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
return axios
|
||||||
|
.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||||
|
method: "POST",
|
||||||
|
data: comicBookMetadata,
|
||||||
|
// transformResponse: (r: string) => JSON.parse(r),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||||
|
importResult: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_CV_METADATA_IMPORT_FAILED,
|
||||||
|
importError: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* return { issueSearchQuery, series: seriesSearchQuery.searchParams }; */
|
export const fetchVolumeGroups = () => async (dispatch) => {
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
const response = await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
dispatch({
|
|
||||||
type: CV_CLEANUP,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
export const fetchComicVineMatches =
|
||||||
|
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||||
|
console.log(issueSearchQuery);
|
||||||
|
try {
|
||||||
|
dispatch({
|
||||||
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
axios
|
||||||
|
.request({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
format: "json",
|
||||||
|
// hack
|
||||||
|
query: issueSearchQuery.inferredIssueDetails.name
|
||||||
|
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||||
|
.trim(),
|
||||||
|
limit: "100",
|
||||||
|
page: 1,
|
||||||
|
resources: "volume",
|
||||||
|
scorerConfiguration: {
|
||||||
|
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||||
|
},
|
||||||
|
rawFileDetails: searchPayload.rawFileDetails,
|
||||||
|
},
|
||||||
|
transformResponse: (r) => {
|
||||||
|
const matches = JSON.parse(r);
|
||||||
|
return matches;
|
||||||
|
// return sortBy(matches, (match) => -match.score);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
let matches: any = [];
|
||||||
|
if (
|
||||||
|
!isNil(response.data.results) &&
|
||||||
|
response.data.results.length === 1
|
||||||
|
) {
|
||||||
|
matches = response.data.results;
|
||||||
|
} else {
|
||||||
|
matches = response.data.map((match) => match);
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: CV_SEARCH_SUCCESS,
|
||||||
|
searchResults: matches,
|
||||||
|
searchQueryObject: {
|
||||||
|
issue: issueSearchQuery,
|
||||||
|
series: seriesSearchQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CV_CLEANUP,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is a proxy to `uncompressFullArchive` which uncompresses complete `rar` or `zip` archives
|
||||||
|
* @param {string} path The path to the compressed archive
|
||||||
|
* @param {any} options Options object
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export const extractComicArchive =
|
||||||
|
(path: string, options: any): any =>
|
||||||
|
async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
filePath: path,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Description
|
||||||
|
* @param {any} query
|
||||||
|
* @param {any} options
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export const searchIssue = (query, options) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||||
|
method: "POST",
|
||||||
|
data: { ...query, ...options },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.code === 404) {
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_FAILED,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (options.trigger) {
|
||||||
|
case "wantedComicsPage":
|
||||||
|
dispatch({
|
||||||
|
type: WANTED_COMICS_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "globalSearchBar":
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "libraryPage":
|
||||||
|
dispatch({
|
||||||
|
type: SS_SEARCH_RESULTS_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "volumesPage":
|
||||||
|
dispatch({
|
||||||
|
type: VOLUMES_FETCHED,
|
||||||
|
data: response.data.hits,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const analyzeImage =
|
||||||
|
(imageFilePath: string | Buffer) => async (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: FILEOPS_STATE_RESET,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const foo = await axios({
|
||||||
|
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
imageFilePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||||
|
result: foo.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
28
src/client/actions/metron.actions.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
import { METRON_SERVICE_URI } from "../constants/endpoints";
|
||||||
|
|
||||||
|
export const fetchMetronResource = async (options) => {
|
||||||
|
const metronResourceResults = await axios.post(
|
||||||
|
`${METRON_SERVICE_URI}/fetchResource`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
console.log(metronResourceResults);
|
||||||
|
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||||
|
const results = metronResourceResults.data.results.map((result) => {
|
||||||
|
return {
|
||||||
|
label: result.name || result.__str__,
|
||||||
|
value: result.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: results,
|
||||||
|
hasMore: !isNil(metronResourceResults.data.next),
|
||||||
|
additional: {
|
||||||
|
page: !isNil(metronResourceResults.data.next)
|
||||||
|
? options.query.page + 1
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
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";
|
@tailwind base;
|
||||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
@tailwind components;
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
@tailwind utilities;
|
||||||
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
|
||||||
$bg-color: yellow;
|
|
||||||
$border-color: red;
|
|
||||||
|
|
||||||
.app {
|
@layer base {
|
||||||
font-family: helvetica, arial, sans-serif;
|
@font-face {
|
||||||
padding: 2em;
|
font-family: "PP Object Sans Regular";
|
||||||
border: 5px solid $border-color;
|
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
@font-face {
|
||||||
background-color: $bg-color;
|
font-family: "Hasklig Regular";
|
||||||
}
|
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,22 @@
|
|||||||
import * as React from "react";
|
import React, { ReactElement, useEffect } from "react";
|
||||||
import { hot } from "react-hot-loader";
|
import { Outlet } from "react-router-dom";
|
||||||
import Dashboard from "./Dashboard";
|
import { Navbar2 } from "./shared/Navbar2";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
import Import from "./Import";
|
|
||||||
import { ComicDetail } from "./ComicDetail";
|
|
||||||
|
|
||||||
import { Switch, Route } from "react-router";
|
|
||||||
import Navbar from "./Navbar";
|
|
||||||
import "../assets/scss/App.scss";
|
import "../assets/scss/App.scss";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
|
||||||
class App extends React.Component {
|
export const App = (): ReactElement => {
|
||||||
public render() {
|
useEffect(() => {
|
||||||
return (
|
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||||
<div>
|
}, []);
|
||||||
<Navbar />
|
|
||||||
<Switch>
|
return (
|
||||||
<Route exact path="/">
|
<>
|
||||||
<Dashboard />
|
<Navbar2 />
|
||||||
</Route>
|
<Outlet />
|
||||||
<Route path="/import">
|
<ToastContainer stacked hideProgressBar />
|
||||||
<Import path={"./comics"} />
|
</>
|
||||||
</Route>
|
);
|
||||||
<Route
|
};
|
||||||
path={"/comic/details/:comicObjectId"}
|
|
||||||
component={ComicDetail}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare let module: Record<string, unknown>;
|
export default App;
|
||||||
|
|
||||||
export default hot(module)(App);
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
|
||||||
import {
|
|
||||||
removeLeadingPeriod,
|
|
||||||
escapePoundSymbol,
|
|
||||||
} from "../shared/utils/formatting.utils";
|
|
||||||
import { isUndefined, isEmpty } from "lodash";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import ellipsize from "ellipsize";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
comicBookCoversMetadata: IExtractedComicBookCoverFile;
|
|
||||||
mongoObjId?: number;
|
|
||||||
}
|
|
||||||
interface IState {}
|
|
||||||
|
|
||||||
class Card extends React.Component<IProps, IState> {
|
|
||||||
constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
public drawCoverCard = (
|
|
||||||
metadata: IExtractedComicBookCoverFile,
|
|
||||||
): JSX.Element => {
|
|
||||||
const filePath = encodeURI(
|
|
||||||
"http://localhost:3000" +
|
|
||||||
removeLeadingPeriod(metadata.path) +
|
|
||||||
"/" +
|
|
||||||
metadata.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="card generic-card">
|
|
||||||
<div>
|
|
||||||
<div className="card-image">
|
|
||||||
<figure className="image">
|
|
||||||
<img
|
|
||||||
src={escapePoundSymbol(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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isUndefined(this.props.comicBookCoversMetadata) &&
|
|
||||||
!isEmpty(this.props.comicBookCoversMetadata) &&
|
|
||||||
this.drawCoverCard(this.props.comicBookCoversMetadata)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Card;
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, ReactElement } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import axios from "axios";
|
|
||||||
import Card from "./Card";
|
|
||||||
import MatchResult from "./MatchResult";
|
|
||||||
import ComicVineSearchForm from "./ComicVineSearchForm";
|
|
||||||
|
|
||||||
import { css } from "@emotion/react";
|
|
||||||
import PuffLoader from "react-spinners/PuffLoader";
|
|
||||||
import { isEmpty, isUndefined } from "lodash";
|
|
||||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
|
||||||
import { fetchComicVineMatches } from "../actions/fileops.actions";
|
|
||||||
import { Drawer, Divider } from "antd";
|
|
||||||
const prettyBytes = require("pretty-bytes");
|
|
||||||
import "antd/dist/antd.css";
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
type ComicDetailProps = {};
|
|
||||||
|
|
||||||
export const ComicDetail = ({}: ComicDetailProps): ReactElement => {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const [comicDetail, setComicDetail] = useState<{
|
|
||||||
rawFileDetails: IExtractedComicBookCoverFile;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const comicVineSearchResults = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.searchResults,
|
|
||||||
);
|
|
||||||
const comicVineSearchQueryObject = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.searchQuery,
|
|
||||||
);
|
|
||||||
const comicVineAPICallProgress = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.inProgress,
|
|
||||||
);
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
axios
|
|
||||||
.request({
|
|
||||||
url: `http://localhost:3000/api/import/getComicBookById`,
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
id: comicObjectId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
setComicDetail(response.data);
|
|
||||||
})
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const openDrawerWithCVMatches = useCallback(() => {
|
|
||||||
setVisible(true);
|
|
||||||
dispatch(fetchComicVineMatches(comicDetail));
|
|
||||||
}, [dispatch, comicDetail]);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
setVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="container">
|
|
||||||
{!isEmpty(comicDetail) && !isUndefined(comicDetail) && (
|
|
||||||
<>
|
|
||||||
<h1 className="title">{comicDetail.rawFileDetails.name}</h1>
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column is-narrow">
|
|
||||||
<Card comicBookCoversMetadata={comicDetail.rawFileDetails} />
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<p>{comicDetail.rawFileDetails.containedIn}</p>
|
|
||||||
<p>{prettyBytes(comicDetail.rawFileDetails.fileSize)}</p>
|
|
||||||
<button className="button" onClick={openDrawerWithCVMatches}>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-magic"></i>
|
|
||||||
</span>
|
|
||||||
<span>Match on Comic Vine</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
title="ComicVine Search Results"
|
|
||||||
placement="right"
|
|
||||||
width={640}
|
|
||||||
closable={false}
|
|
||||||
onClose={onClose}
|
|
||||||
visible={visible}
|
|
||||||
className="comic-vine-match-drawer"
|
|
||||||
>
|
|
||||||
{!isEmpty(comicVineSearchQueryObject) &&
|
|
||||||
!isUndefined(comicVineSearchQueryObject) ? (
|
|
||||||
<div className="card search-criteria-card">
|
|
||||||
<div className="card-content">
|
|
||||||
<ComicVineSearchForm />
|
|
||||||
<Divider />
|
|
||||||
<p className="is-size-6">Searching against:</p>
|
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Title</span>
|
|
||||||
<span className="tag is-info">
|
|
||||||
{
|
|
||||||
comicVineSearchQueryObject.issue.searchParams
|
|
||||||
.searchTerms.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">Number</span>
|
|
||||||
<span className="tag is-info">
|
|
||||||
{
|
|
||||||
comicVineSearchQueryObject.issue.searchParams
|
|
||||||
.searchTerms.number
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="progress-indicator-container">
|
|
||||||
<div className="indicator">
|
|
||||||
<PuffLoader loading={comicVineAPICallProgress} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="search-results-container">
|
|
||||||
{!isEmpty(comicVineSearchResults) && (
|
|
||||||
<MatchResult matchData={comicVineSearchResults} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
607
src/client/components/ComicDetail/AcquisitionPanel.tsx
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
ReactElement,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { SearchQuery, PriorityEnum, SearchResponse, 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, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
interface IAcquisitionPanelProps {
|
||||||
|
query: any;
|
||||||
|
comicObjectId: string;
|
||||||
|
comicObject: any;
|
||||||
|
settings: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirDCPPConfig {
|
||||||
|
protocol: string;
|
||||||
|
hostname: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: {
|
||||||
|
id: string;
|
||||||
|
str: string;
|
||||||
|
};
|
||||||
|
size: number;
|
||||||
|
slots: {
|
||||||
|
total: number;
|
||||||
|
free: number;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
user: {
|
||||||
|
nicks: string;
|
||||||
|
flags: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dupe?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInstanceData {
|
||||||
|
id: number;
|
||||||
|
owner: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInfo {
|
||||||
|
query: {
|
||||||
|
pattern: string;
|
||||||
|
extensions: string[];
|
||||||
|
file_type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hub {
|
||||||
|
hub_url: string;
|
||||||
|
identity: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFormValues {
|
||||||
|
issueName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AcquisitionPanel = (
|
||||||
|
props: IAcquisitionPanelProps,
|
||||||
|
): ReactElement => {
|
||||||
|
const socketRef = useRef<Socket>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null);
|
||||||
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<SearchInstanceData | null>(null);
|
||||||
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<SearchInfo | null>(null);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { comicObjectId } = props;
|
||||||
|
const issueName = props.query.issue.name || "";
|
||||||
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
|
|
||||||
|
// Search timeout duration in milliseconds (30 seconds)
|
||||||
|
const SEARCH_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = useStore.getState().getSocket("manual");
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
const handleResultAdded = ({ result }: { result: SearchResult }) => {
|
||||||
|
setAirDCPPSearchResults((prev) =>
|
||||||
|
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultUpdated = ({ result }: { result: SearchResult }) => {
|
||||||
|
setAirDCPPSearchResults((prev) => {
|
||||||
|
const idx = prev.findIndex((r) => r.id === result.id);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = result;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchInitiated = (data: { instance: SearchInstanceData }) => {
|
||||||
|
setAirDCPPSearchInstance(data.instance);
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a timeout to stop searching after SEARCH_TIMEOUT_MS
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsSearching(false);
|
||||||
|
console.log(`Search timeout reached after ${SEARCH_TIMEOUT_MS / 1000} seconds`);
|
||||||
|
}, SEARCH_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchesSent = (data: { searchInfo: SearchInfo }) => {
|
||||||
|
setAirDCPPSearchInfo(data.searchInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchError = (error: { message: string }) => {
|
||||||
|
setSearchError(error.message || "Search failed");
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
// Clear timeout on error
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
searchTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchCompleted = () => {
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
// Clear timeout when search completes
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
searchTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Subscribe once ---
|
||||||
|
socket.on("searchResultAdded", handleResultAdded);
|
||||||
|
socket.on("searchResultUpdated", handleResultUpdated);
|
||||||
|
socket.on("searchInitiated", handleSearchInitiated);
|
||||||
|
socket.on("searchesSent", handleSearchesSent);
|
||||||
|
socket.on("searchError", handleSearchError);
|
||||||
|
socket.on("searchCompleted", handleSearchCompleted);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("searchResultAdded", handleResultAdded);
|
||||||
|
socket.off("searchResultUpdated", handleResultUpdated);
|
||||||
|
socket.off("searchInitiated", handleSearchInitiated);
|
||||||
|
socket.off("searchesSent", handleSearchesSent);
|
||||||
|
socket.off("searchError", handleSearchError);
|
||||||
|
socket.off("searchCompleted", handleSearchCompleted);
|
||||||
|
|
||||||
|
// Clean up timeout on unmount
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [SEARCH_TIMEOUT_MS]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading: isLoadingSettings,
|
||||||
|
isError: isSettingsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: hubs, isLoading: isLoadingHubs } = useQuery({
|
||||||
|
queryKey: ["hubs", settings?.data.directConnect?.client?.host],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
host: settings?.data.directConnect?.client?.host,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: !!settings?.data?.directConnect?.client?.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get AirDC++ config from settings
|
||||||
|
const airDCPPConfig: AirDCPPConfig | null = settings?.data?.directConnect?.client
|
||||||
|
? {
|
||||||
|
protocol: settings.data.directConnect.client.protocol || "ws",
|
||||||
|
hostname: typeof settings.data.directConnect.client.host === 'string'
|
||||||
|
? settings.data.directConnect.client.host
|
||||||
|
: `${settings.data.directConnect.client.host?.hostname || 'localhost'}:${settings.data.directConnect.client.host?.port || '5600'}`,
|
||||||
|
username: settings.data.directConnect.client.username || "admin",
|
||||||
|
password: settings.data.directConnect.client.password || "password",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
|
||||||
|
const dcppSearchQuery = {
|
||||||
|
query: {
|
||||||
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
|
},
|
||||||
|
hub_urls: map(hubs.data, (item) => item.value),
|
||||||
|
priority: 5,
|
||||||
|
};
|
||||||
|
setDcppQuery(dcppSearchQuery as any);
|
||||||
|
}
|
||||||
|
}, [hubs, sanitizedIssueName]);
|
||||||
|
|
||||||
|
const search = async (searchData: any) => {
|
||||||
|
if (!airDCPPConfig) {
|
||||||
|
setSearchError("AirDC++ configuration not found in settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socketRef.current) {
|
||||||
|
setSearchError("Socket connection not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAirDCPPSearchResults([]);
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
socketRef.current.emit("call", "socket.search", {
|
||||||
|
query: searchData,
|
||||||
|
namespace: "/manual",
|
||||||
|
config: airDCPPConfig,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (
|
||||||
|
searchInstanceId: number,
|
||||||
|
resultId: string,
|
||||||
|
comicObjectId: string,
|
||||||
|
name: string,
|
||||||
|
size: number,
|
||||||
|
type: SearchResult["type"],
|
||||||
|
config: AirDCPPConfig,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!socketRef.current) {
|
||||||
|
console.error("Socket connection not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socketRef.current.emit(
|
||||||
|
"call",
|
||||||
|
"socket.download",
|
||||||
|
{
|
||||||
|
searchInstanceId,
|
||||||
|
resultId,
|
||||||
|
comicObjectId,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
config,
|
||||||
|
},
|
||||||
|
(data: any) => console.log("Download initiated:", data),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDCPPSearchResults = async (searchQuery: SearchFormValues) => {
|
||||||
|
if (!searchQuery.issueName || searchQuery.issueName.trim() === "") {
|
||||||
|
setSearchError("Please enter a search term");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hubs?.data || !Array.isArray(hubs.data) || hubs.data.length === 0) {
|
||||||
|
setSearchError("No hubs configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualQuery = {
|
||||||
|
query: {
|
||||||
|
pattern: `${searchQuery.issueName.trim()}`,
|
||||||
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
|
},
|
||||||
|
hub_urls: [hubs.data[0].hub_url],
|
||||||
|
priority: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
search(manualQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-5 mb-3">
|
||||||
|
{isLoadingSettings || isLoadingHubs ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin" />
|
||||||
|
Loading configuration...
|
||||||
|
</div>
|
||||||
|
) : !isEmpty(hubs?.data) ? (
|
||||||
|
<Form
|
||||||
|
onSubmit={getDCPPSearchResults}
|
||||||
|
initialValues={{
|
||||||
|
issueName,
|
||||||
|
}}
|
||||||
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Field name="issueName">
|
||||||
|
{({ input, meta }) => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-fit">
|
||||||
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||||
|
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||||
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="sm:mt-0 min-w-fit 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{isSearching ? (
|
||||||
|
<>
|
||||||
|
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" />
|
||||||
|
Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Search DC++
|
||||||
|
<div className="h-5 w-5 ml-2">
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm 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"
|
||||||
|
>
|
||||||
|
No AirDC++ hub configured. Please configure it in{" "}
|
||||||
|
<code>Settings > AirDC++ > Hubs</code>.
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Error Display */}
|
||||||
|
{searchError && (
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-red-500 bg-red-50 p-4 dark:border-s-4 dark:border-red-600 dark:bg-red-300 dark:text-slate-600"
|
||||||
|
>
|
||||||
|
<strong>Error:</strong> {searchError}
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
{/* configured hub */}
|
||||||
|
{!isEmpty(hubs?.data) && hubs?.data[0] && (
|
||||||
|
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
{hubs.data[0].hub_url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AirDC++ search instance details */}
|
||||||
|
{airDCPPSearchInstance &&
|
||||||
|
airDCPPSearchInfo &&
|
||||||
|
hubs?.data && (
|
||||||
|
<div className="flex flex-row gap-3 my-5 font-hasklig">
|
||||||
|
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
|
<dl>
|
||||||
|
<dt>
|
||||||
|
<div className="mb-1">
|
||||||
|
{hubs.data.map((value: Hub, idx: number) => (
|
||||||
|
<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 className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
|
<dl>
|
||||||
|
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
|
||||||
|
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
|
||||||
|
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AirDC++ results */}
|
||||||
|
<div className="">
|
||||||
|
{airDCPPSearchResults.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto max-w-full mt-6">
|
||||||
|
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||||
|
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Slots
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{map(
|
||||||
|
airDCPPSearchResults,
|
||||||
|
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={
|
||||||
|
!isNil(dupe)
|
||||||
|
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
|
||||||
|
: "border-b border-gray-200 dark:border-slate-700 text-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* NAME */}
|
||||||
|
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||||
|
<p className="mb-2">
|
||||||
|
{type.id === "directory" && (
|
||||||
|
<i className="fas fa-folder mr-1"></i>
|
||||||
|
)}
|
||||||
|
{ellipsize(name, 45)}
|
||||||
|
</p>
|
||||||
|
<dl>
|
||||||
|
<dd>
|
||||||
|
<div className="inline-flex flex-wrap gap-1">
|
||||||
|
{!isNil(dupe) && (
|
||||||
|
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||||
|
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||||
|
Dupe
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||||
|
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||||
|
{users.user.nicks}
|
||||||
|
</span>
|
||||||
|
{users.user.flags.map((flag: string, flagIdx: number) => (
|
||||||
|
<span
|
||||||
|
key={flagIdx}
|
||||||
|
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
|
||||||
|
>
|
||||||
|
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||||
|
{flag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* TYPE */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||||
|
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||||
|
{type.str}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* SLOTS */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||||
|
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||||
|
{slots.total} slots; {slots.free} free
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => {
|
||||||
|
if (airDCPPSearchInstance && airDCPPConfig) {
|
||||||
|
download(
|
||||||
|
airDCPPSearchInstance.id,
|
||||||
|
id,
|
||||||
|
comicObjectId,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
airDCPPConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!airDCPPSearchInstance || !airDCPPConfig}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : !isSearching ? (
|
||||||
|
<div className="">
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm 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>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm 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>
|
||||||
|
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 className="font-hasklig">ADCS</code> hubs are more
|
||||||
|
reliable than <code className="font-hasklig">NMDCS</code> ones.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-6 p-4">
|
||||||
|
<i className="icon-[solar--refresh-bold-duotone] h-6 w-6 animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AcquisitionPanel;
|
||||||
32
src/client/components/ComicDetail/ActionMenu/Menu.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import Select from "react-select";
|
||||||
|
|
||||||
|
export const Menu = (props: any): ReactElement => {
|
||||||
|
const {
|
||||||
|
filteredActionOptions,
|
||||||
|
customStyles,
|
||||||
|
handleActionSelection,
|
||||||
|
Placeholder,
|
||||||
|
} = props.configuration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
components={{ Placeholder }}
|
||||||
|
placeholder={
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5 pt-1">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Select An Action</div>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={customStyles}
|
||||||
|
name="actions"
|
||||||
|
isSearchable={false}
|
||||||
|
options={filteredActionOptions}
|
||||||
|
onChange={handleActionSelection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Menu;
|
||||||
62
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { map } from "lodash";
|
||||||
|
import { DownloadProgressTick } from "./DownloadProgressTick";
|
||||||
|
export const AirDCPPBundles = (props) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto w-fit mt-6">
|
||||||
|
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||||
|
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Filename
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Download Status
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||||
|
Bundle ID
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{map(props.data, (bundle, index) => (
|
||||||
|
<tr
|
||||||
|
key={bundle.id}
|
||||||
|
className={
|
||||||
|
Number(index) !== props.data.length - 1
|
||||||
|
? "border-b border-gray-200 dark:border-slate-700"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 align-top">
|
||||||
|
<h5 className="font-medium text-gray-800 dark:text-slate-200">
|
||||||
|
{ellipsize(bundle.name, 58)}
|
||||||
|
</h5>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||||
|
{ellipsize(bundle.target, 88)}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top">
|
||||||
|
{prettyBytes(bundle.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top">
|
||||||
|
<DownloadProgressTick bundleId={bundle.id} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top">
|
||||||
|
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium">
|
||||||
|
{bundle.id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { ReactElement, useCallback, useState } from "react";
|
||||||
|
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||||
|
import Creatable from "react-select/creatable";
|
||||||
|
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||||
|
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||||
|
|
||||||
|
interface AsyncSelectPaginateProps {
|
||||||
|
metronResource: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: object;
|
||||||
|
onChange?(...args: unknown[]): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsyncSelectPaginate;
|
||||||
500
src/client/components/ComicDetail/ComicDetail.tsx
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
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 TorrentSearchPanel from "./TorrentSearchPanel";
|
||||||
|
import DownloadsPanel from "./DownloadsPanel";
|
||||||
|
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||||
|
|
||||||
|
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||||
|
import { components } from "react-select";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import axios from "axios";
|
||||||
|
import { styled } from "styled-components";
|
||||||
|
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||||
|
import { refineQuery } from "filename-parser";
|
||||||
|
|
||||||
|
interface ComicDetailProps {
|
||||||
|
data: {
|
||||||
|
_id: string;
|
||||||
|
rawFileDetails?: any;
|
||||||
|
inferredMetadata?: {
|
||||||
|
issue?: {
|
||||||
|
year?: string;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
sourcedMetadata: {
|
||||||
|
comicvine?: any;
|
||||||
|
locg?: any;
|
||||||
|
comicInfo?: any;
|
||||||
|
};
|
||||||
|
acquisition?: {
|
||||||
|
directconnect?: {
|
||||||
|
downloads?: any[];
|
||||||
|
};
|
||||||
|
torrent?: any[];
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
userSettings?: any;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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 [comicVineMatches, setComicVineMatches] = useState([]);
|
||||||
|
|
||||||
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
|
||||||
|
// const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const openModal = useCallback((filePath: string) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
// dispatch(
|
||||||
|
// extractComicArchive(filePath, {
|
||||||
|
// type: "full",
|
||||||
|
// purpose: "reading",
|
||||||
|
// imageResizeOptions: {
|
||||||
|
// baseWidth: 1024,
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// overridden <SlidingPanel> with some styles
|
||||||
|
const StyledSlidingPanel = styled(SlidingPane)`
|
||||||
|
background: #ccc;
|
||||||
|
`;
|
||||||
|
const afterOpenModal = useCallback((things: any) => {
|
||||||
|
// 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: Record<string, { content: (props?: any) => JSX.Element }> = {
|
||||||
|
CVMatches: {
|
||||||
|
content: (props?: any) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<ComicVineSearchForm data={rawFileDetails} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||||
|
<p className="">Searching for:</p>
|
||||||
|
{inferredMetadata?.issue ? (
|
||||||
|
<>
|
||||||
|
<span className="">{inferredMetadata.issue.name} </span>
|
||||||
|
<span className=""> # {inferredMetadata.issue.number} </span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ComicVineMatchPanel
|
||||||
|
props={{
|
||||||
|
comicVineMatches,
|
||||||
|
comicObjectId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
editComicBookMetadata: {
|
||||||
|
content: () => <EditMetadataPanel data={rawFileDetails} />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
const fetchComicVineMatches = async (
|
||||||
|
searchPayload: any,
|
||||||
|
issueSearchQuery: any,
|
||||||
|
seriesSearchQuery: any,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await axios({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
transformResponse: (r: string) => {
|
||||||
|
const matches = JSON.parse(r);
|
||||||
|
return matches;
|
||||||
|
// return sortBy(matches, (match) => -match.score);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let matches: any = [];
|
||||||
|
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||||
|
matches = response.data.results;
|
||||||
|
} else {
|
||||||
|
matches = response.data.map((match: any) => match);
|
||||||
|
}
|
||||||
|
const scoredMatches = matches.sort((a: any, b: any) => b.score - a.score);
|
||||||
|
setComicVineMatches(scoredMatches);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action event handlers
|
||||||
|
const openDrawerWithCVMatches = () => {
|
||||||
|
let seriesSearchQuery: any = {};
|
||||||
|
let issueSearchQuery: any = {};
|
||||||
|
|
||||||
|
if (!isUndefined(rawFileDetails)) {
|
||||||
|
issueSearchQuery = refineQuery((rawFileDetails as any).name);
|
||||||
|
} else if (!isEmpty(comicvine)) {
|
||||||
|
issueSearchQuery = refineQuery((comicvine as any).name);
|
||||||
|
}
|
||||||
|
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||||
|
setSlidingPanelContentId("CVMatches");
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditMetadataPanel = useCallback(() => {
|
||||||
|
setSlidingPanelContentId("editComicBookMetadata");
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Actions menu options and handler
|
||||||
|
const CVMatchLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Match on ComicVine</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const editLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Edit Metadata</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const deleteLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Delete Comic</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const Placeholder = (props: any) => {
|
||||||
|
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(rawFileDetails)) {
|
||||||
|
return item.value !== "match-on-comic-vine";
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
const handleActionSelection = (action: any) => {
|
||||||
|
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 = {
|
||||||
|
menu: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
placeholder: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
color: "black",
|
||||||
|
}),
|
||||||
|
option: (base: any, { data, isDisabled, isFocused, isSelected }: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
singleValue: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
paddingTop: "0.4rem",
|
||||||
|
}),
|
||||||
|
control: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
color: "black",
|
||||||
|
border: "1px solid rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// check for the availability of CV metadata
|
||||||
|
const isComicBookMetadataAvailable =
|
||||||
|
!isUndefined(comicvine) && !isUndefined((comicvine as any)?.volumeInformation);
|
||||||
|
|
||||||
|
// check for the availability of rawFileDetails
|
||||||
|
const areRawFileDetailsAvailable =
|
||||||
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||||
|
|
||||||
|
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="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||||
|
),
|
||||||
|
content: isComicBookMetadataAvailable ? (
|
||||||
|
<VolumeInformation data={data.data} key={1} />
|
||||||
|
) : null,
|
||||||
|
shouldShow: isComicBookMetadataAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "ComicInfo.xml",
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<div key={2}>
|
||||||
|
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
shouldShow: !isEmpty(comicInfo),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
|
name: "Archive Operations",
|
||||||
|
content: <ArchiveOperations data={data.data} key={3} />,
|
||||||
|
shouldShow: areRawFileDetailsAvailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
|
name: "DC++ Search",
|
||||||
|
content: (
|
||||||
|
<AcquisitionPanel
|
||||||
|
query={airDCPPQuery}
|
||||||
|
comicObjectId={_id}
|
||||||
|
comicObject={data.data}
|
||||||
|
settings={userSettings}
|
||||||
|
key={4}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
icon: (
|
||||||
|
<span className="inline-flex flex-row">
|
||||||
|
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
name: "Torrent Search",
|
||||||
|
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Downloads",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
{(acquisition?.directconnect?.downloads?.length || 0) +
|
||||||
|
(acquisition?.torrent?.length || 0)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
content:
|
||||||
|
!isNil(data.data) && !isEmpty(data.data) ? (
|
||||||
|
<DownloadsPanel key={5} />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
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}
|
||||||
|
cardContainerStyle={{ maxWidth: "290px", width: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* raw file details */}
|
||||||
|
{!isUndefined(rawFileDetails) &&
|
||||||
|
!isEmpty((rawFileDetails as any)?.cover) && (
|
||||||
|
<div className="grid">
|
||||||
|
<RawFileDetails
|
||||||
|
data={{
|
||||||
|
rawFileDetails: rawFileDetails,
|
||||||
|
inferredMetadata: inferredMetadata,
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* action dropdown */}
|
||||||
|
<div className="mt-1 flex flex-row gap-2 w-full">
|
||||||
|
<Menu
|
||||||
|
data={data.data}
|
||||||
|
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||||
|
configuration={{
|
||||||
|
filteredActionOptions,
|
||||||
|
customStyles,
|
||||||
|
handleActionSelection,
|
||||||
|
Placeholder,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RawFileDetails>
|
||||||
|
|
||||||
|
{/* <Modal
|
||||||
|
style={{ content: { marginTop: "2rem" } }}
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
onAfterOpen={afterOpenModal}
|
||||||
|
onRequestClose={closeModal}
|
||||||
|
contentLabel="Example Modal"
|
||||||
|
>
|
||||||
|
<button onClick={closeModal}>close</button>
|
||||||
|
{extractedComicBook && (
|
||||||
|
<ComicViewer
|
||||||
|
pages={extractedComicBook}
|
||||||
|
direction="ltr"
|
||||||
|
className={{
|
||||||
|
closeButton: "border: 1px solid red;",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabControls
|
||||||
|
filteredTabs={filteredTabs}
|
||||||
|
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StyledSlidingPanel
|
||||||
|
isOpen={visible}
|
||||||
|
onRequestClose={() => setVisible(false)}
|
||||||
|
title={"Comic Vine Search Matches"}
|
||||||
|
width={"600px"}
|
||||||
|
>
|
||||||
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
|
</StyledSlidingPanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicDetail;
|
||||||
35
src/client/components/ComicDetail/ComicDetailContainer.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
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: ["comicBookMetadata"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
id: comicObjectId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
isError && <>Error</>;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isLoading && <>Loading...</>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
|
||||||
|
);
|
||||||
|
};
|
||||||
118
src/client/components/ComicDetail/ComicVineDetails.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
|
import Card from "../shared/Carda";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
|
||||||
|
interface ComicVineDetailsProps {
|
||||||
|
updatedAt?: string;
|
||||||
|
data?: {
|
||||||
|
name?: string;
|
||||||
|
number?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||||
|
const { data, updatedAt } = props;
|
||||||
|
return (
|
||||||
|
<div className="text-slate-500 dark:text-gray-400">
|
||||||
|
<div className="">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<div className="min-w-fit">
|
||||||
|
<Card
|
||||||
|
imageUrl={data.volumeInformation.image.thumb_url}
|
||||||
|
orientation={"cover-only"}
|
||||||
|
hasDetails={false}
|
||||||
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<div className="text-lg">{data.name}</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Is a part of{" "}
|
||||||
|
<span className="has-text-info">
|
||||||
|
{data.volumeInformation.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comicvine metadata */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-md">ComicVine Metadata</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Last scraped on{" "}
|
||||||
|
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
ComicVine Issue ID
|
||||||
|
<span>{data.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publisher details */}
|
||||||
|
<div className="ml-8">
|
||||||
|
Published by{" "}
|
||||||
|
<span>{data.volumeInformation.publisher.name}</span>
|
||||||
|
<div>
|
||||||
|
Total issues in this volume{" "}
|
||||||
|
<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="text-md text-slate-900 dark:text-slate-900">
|
||||||
|
{data.volumeInformation.count_of_issues}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{data.issue_number && (
|
||||||
|
<div className="">
|
||||||
|
<span>Issue Number</span>
|
||||||
|
<span>{data.issue_number}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUndefined(
|
||||||
|
detectIssueTypes(data.volumeInformation.description),
|
||||||
|
) ? (
|
||||||
|
<div>
|
||||||
|
<span>Detected Type</span>
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
detectIssueTypes(data.volumeInformation.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : data.resource_type ? (
|
||||||
|
<div>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>{data.resource_type}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mt-3 w-3/4">
|
||||||
|
{!isEmpty(data.description) &&
|
||||||
|
convert(data.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineDetails;
|
||||||
46
src/client/components/ComicDetail/ComicVineMatchPanel.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||||
|
import MatchResult from "./MatchResult";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
|
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||||
|
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||||
|
const { comicvine } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
comicvine: state.comicvine,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{!isEmpty(comicVineMatches) ? (
|
||||||
|
<MatchResult
|
||||||
|
matchData={comicVineMatches}
|
||||||
|
comicObjectId={comicObjectId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 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 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>ComicVine match results are an approximation.</p>
|
||||||
|
<p>
|
||||||
|
Auto-matching is not available yet. If you see no results or
|
||||||
|
poor quality ones, you can override the search query
|
||||||
|
parameters to get better ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div className="text-md my-5">{comicvine.scrapingStatus}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineMatchPanel;
|
||||||
96
src/client/components/ComicDetail/ComicVineSearchForm.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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 performing search against ComicVine
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* return (
|
||||||
|
* <ComicVineSearchForm data={rawFileDetails} />
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MyForm = () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validate={validate}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
|
||||||
|
Override Search Query
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
|
</span>
|
||||||
|
<label className="block py-1">Issue Name</label>
|
||||||
|
<Field name="issueName">
|
||||||
|
{(props) => (
|
||||||
|
<input
|
||||||
|
{...props.input}
|
||||||
|
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-full 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"
|
||||||
|
placeholder="Type the issue name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block py-1">Number</label>
|
||||||
|
<Field name="issueNumber">
|
||||||
|
{(props) => (
|
||||||
|
<input
|
||||||
|
{...props.input}
|
||||||
|
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-14 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"
|
||||||
|
placeholder="#"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block py-1">Year</label>
|
||||||
|
<Field name="issueYear">
|
||||||
|
{(props) => (
|
||||||
|
<input
|
||||||
|
{...props.input}
|
||||||
|
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 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"
|
||||||
|
placeholder="1984"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-5">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex h-10 sm:mt-3 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-red-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <MyForm />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicVineSearchForm;
|
||||||
131
src/client/components/ComicDetail/DownloadProgressTick.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import React, { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DownloadProgressTickProps
|
||||||
|
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
|
||||||
|
*/
|
||||||
|
interface DownloadProgressTickProps {
|
||||||
|
bundleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the download tick data received over the socket.
|
||||||
|
*
|
||||||
|
* @typedef DownloadTickData
|
||||||
|
* @property {number} id - Unique download ID
|
||||||
|
* @property {string} name - File name (e.g. "movie.mkv")
|
||||||
|
* @property {number} downloaded_bytes - Bytes downloaded so far
|
||||||
|
* @property {number} size - Total size in bytes
|
||||||
|
* @property {number} speed - Current download speed (bytes/sec)
|
||||||
|
* @property {number} seconds_left - Estimated seconds remaining
|
||||||
|
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
|
||||||
|
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
|
||||||
|
* @property {{ online: number; total: number; str: string }} sources
|
||||||
|
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
|
||||||
|
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
|
||||||
|
*/
|
||||||
|
interface DownloadTickData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
downloaded_bytes: number;
|
||||||
|
size: number;
|
||||||
|
speed: number;
|
||||||
|
seconds_left: number;
|
||||||
|
status: {
|
||||||
|
id: string;
|
||||||
|
str: string;
|
||||||
|
completed: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
hook_error: any;
|
||||||
|
};
|
||||||
|
sources: {
|
||||||
|
online: number;
|
||||||
|
total: number;
|
||||||
|
str: string;
|
||||||
|
};
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||||
|
bundleId,
|
||||||
|
}): ReactElement | null => {
|
||||||
|
const socketRef = useRef<Socket>();
|
||||||
|
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = useStore.getState().getSocket("manual");
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.emit("call", "socket.listenFileProgress", {
|
||||||
|
namespace: "/manual",
|
||||||
|
config: {
|
||||||
|
protocol: `ws`,
|
||||||
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for each "downloadTick" event.
|
||||||
|
* Only update state if event.id matches bundleId.
|
||||||
|
*
|
||||||
|
* @param {DownloadTickData} data - Payload from the server
|
||||||
|
*/
|
||||||
|
const onDownloadTick = (data: DownloadTickData) => {
|
||||||
|
// Compare numeric data.id to string bundleId
|
||||||
|
console.log(data.id);
|
||||||
|
console.log(`bundleId is ${bundleId}`)
|
||||||
|
if (data.id === parseInt(bundleId, 10)) {
|
||||||
|
setTick(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("downloadTick", onDownloadTick);
|
||||||
|
return () => {
|
||||||
|
socket.off("downloadTick", onDownloadTick);
|
||||||
|
};
|
||||||
|
}, [socketRef, bundleId]);
|
||||||
|
|
||||||
|
if (!tick) {
|
||||||
|
return <>Nothing detected.</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute human-readable values and percentages
|
||||||
|
const downloaded = prettyBytes(tick.downloaded_bytes);
|
||||||
|
const total = prettyBytes(tick.size);
|
||||||
|
const percent = tick.size > 0
|
||||||
|
? Math.round((tick.downloaded_bytes / tick.size) * 100)
|
||||||
|
: 0;
|
||||||
|
const speed = prettyBytes(tick.speed) + "/s";
|
||||||
|
const minutesLeft = Math.round(tick.seconds_left / 60);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||||
|
{/* Downloaded vs Total */}
|
||||||
|
<div className="mt-1 flex items-center space-x-2">
|
||||||
|
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-green-500"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
|
||||||
|
|
||||||
|
{/* Speed and Time Left */}
|
||||||
|
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
|
||||||
|
<span>Speed: {speed}</span>
|
||||||
|
<span>Time left: {minutesLeft} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadProgressTick;
|
||||||
154
src/client/components/ComicDetail/DownloadsPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||||
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
|
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||||
|
import { TorrentDownloads } from "./TorrentDownloads";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
|
TORRENT_JOB_SERVICE_BASE_URI,
|
||||||
|
} from "../../constants/endpoints";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export interface TorrentDetails {
|
||||||
|
infoHash: string;
|
||||||
|
progress: number;
|
||||||
|
downloadSpeed?: number;
|
||||||
|
uploadSpeed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||||
|
* - DC++ (AirDCPP) bundles
|
||||||
|
* - Torrent downloads
|
||||||
|
* It also listens for real-time torrent updates via a WebSocket.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
|
||||||
|
*/
|
||||||
|
export const DownloadsPanel = (): ReactElement | null => {
|
||||||
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||||
|
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||||
|
"directconnect",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { socketIOInstance } = useStore(
|
||||||
|
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers socket listeners on mount and cleans up on unmount.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socketIOInstance) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for incoming torrent data events.
|
||||||
|
* Merges new entries or updates existing ones by infoHash.
|
||||||
|
*
|
||||||
|
* @param {TorrentDetails} data - Payload from the socket event.
|
||||||
|
*/
|
||||||
|
const handleTorrentData = (data: TorrentDetails) => {
|
||||||
|
setTorrentDetails((prev) => {
|
||||||
|
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
|
||||||
|
if (idx === -1) {
|
||||||
|
return [...prev, data];
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], ...data };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
|
||||||
|
};
|
||||||
|
}, [socketIOInstance]);
|
||||||
|
|
||||||
|
// ————— DC++ Bundles (via REST) —————
|
||||||
|
const { data: bundles } = useQuery({
|
||||||
|
queryKey: ["bundles", comicObjectId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
comicObjectId,
|
||||||
|
config: {
|
||||||
|
protocol: `ws`,
|
||||||
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ————— Torrent Jobs (via REST) —————
|
||||||
|
const { data: rawJobs = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ["torrents", comicObjectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await axios.get(
|
||||||
|
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||||
|
{ params: { trigger: activeTab } },
|
||||||
|
);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
initialData: [],
|
||||||
|
enabled: activeTab === "torrents",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "torrents") return;
|
||||||
|
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-5 mb-3">
|
||||||
|
<nav className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("directconnect")}
|
||||||
|
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "directconnect"
|
||||||
|
? "bg-green-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DC++
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("torrents")}
|
||||||
|
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "torrents"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Torrents
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{activeTab === "torrents" ? (
|
||||||
|
<TorrentDownloads data={torrentDetails} />
|
||||||
|
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
|
||||||
|
<AirDCPPBundles data={bundles.data} />
|
||||||
|
) : (
|
||||||
|
<p>No DC++ bundles found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadsPanel;
|
||||||
313
src/client/components/ComicDetail/EditMetadataPanel.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
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 { data } = props;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Field
|
||||||
|
name="issue_name"
|
||||||
|
component="input"
|
||||||
|
className="appearance-none w-full 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"
|
||||||
|
initialValue={data.name}
|
||||||
|
placeholder={"Issue Name"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Issue Number and year */}
|
||||||
|
<div className="mt-4 flex flex-row gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Issue Number</div>
|
||||||
|
<Field
|
||||||
|
name="issue_number"
|
||||||
|
component="input"
|
||||||
|
className="dark:bg-slate-400 w-20 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"
|
||||||
|
placeholder="Issue Number"
|
||||||
|
/>
|
||||||
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{/* year */}
|
||||||
|
<div className="text-sm">Issue Year</div>
|
||||||
|
<Field
|
||||||
|
name="issue_year"
|
||||||
|
component="input"
|
||||||
|
className="dark:bg-slate-400 w-20 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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Page Count</div>
|
||||||
|
<Field
|
||||||
|
name="page_count"
|
||||||
|
component="input"
|
||||||
|
className="dark:bg-slate-400 w-20 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"
|
||||||
|
placeholder="Page Count"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* page count */}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="text-sm">Description</label>
|
||||||
|
<Field
|
||||||
|
name={"description"}
|
||||||
|
className="dark:bg-slate-400 w-full min-h-24 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"
|
||||||
|
component={TextareaAutosizeAdapter}
|
||||||
|
placeholder={"Description"}
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
137
src/client/components/ComicDetail/MatchResult.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { isNil, map } from "lodash";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
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 = async (match, comicObjectId) => {
|
||||||
|
return await axios.request({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
match,
|
||||||
|
comicObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center mt-6">
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
|
||||||
|
ComicVine Matches
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
|
</span>
|
||||||
|
{map(props.matchData, (match, idx) => {
|
||||||
|
let issueDescription = "";
|
||||||
|
if (!isNil(match.description)) {
|
||||||
|
issueDescription = convert(match.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
|
||||||
|
return (
|
||||||
|
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<div className="min-w-fit">
|
||||||
|
<img
|
||||||
|
className="rounded-md"
|
||||||
|
src={match.image.thumb_url}
|
||||||
|
onError={handleBrokenImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row mb-1 justify-end">
|
||||||
|
{match.name ? (
|
||||||
|
<p className="text-md w-full">{match.name}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* score */}
|
||||||
|
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
|
{parseInt(match.score, 10)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex flex-row gap-2 mb-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium 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-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
|
{parseInt(match.issue_number, 10)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--calendar-mark-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
|
Cover Date: {match.cover_date}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="text-sm">
|
||||||
|
{ellipsize(issueDescription, 300)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 my-4 ml-10">
|
||||||
|
<div className="">
|
||||||
|
<img
|
||||||
|
src={match.volumeInformation.results.image.icon_url}
|
||||||
|
className="rounded-md w-full"
|
||||||
|
onError={handleBrokenImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
<span>{match.volume.name}</span>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p>
|
||||||
|
Total Issues:
|
||||||
|
{match.volumeInformation.results.count_of_issues}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Published by{" "}
|
||||||
|
{match.volumeInformation.results.publisher.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<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={() => applyCVMatch(match, props.comicObjectId)}
|
||||||
|
>
|
||||||
|
<span className="text-md">Apply Match</span>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchResult;
|
||||||
157
src/client/components/ComicDetail/RawFileDetails.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
interface RawFileDetailsProps {
|
||||||
|
data?: {
|
||||||
|
rawFileDetails?: {
|
||||||
|
containedIn?: string;
|
||||||
|
name?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
path?: string;
|
||||||
|
extension?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
cover?: {
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
inferredMetadata?: {
|
||||||
|
issue?: {
|
||||||
|
year?: string;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
children?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
|
||||||
|
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||||
|
props.data || {};
|
||||||
|
if (!rawFileDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-w-2xl ml-5">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="px-4 sm:px-6 mb-6">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="text-xl font-semibold">{rawFileDetails?.name}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Binary Details Section */}
|
||||||
|
<div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<i className="icon-[solar--document-bold-duotone] w-5 h-5"></i>
|
||||||
|
File Binary Details
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6">
|
||||||
|
<dl className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
File Path
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-gray-300 font-mono break-all">
|
||||||
|
{rawFileDetails?.containedIn}/{rawFileDetails?.name}{rawFileDetails?.extension}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
MIME Type
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||||
|
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||||
|
{rawFileDetails?.mimeType}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
File Size
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||||
|
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
||||||
|
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : 'N/A'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Details Section */}
|
||||||
|
<div className="mb-8 px-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<i className="icon-[solar--import-bold-duotone] w-5 h-5"></i>
|
||||||
|
Import Details
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6">
|
||||||
|
<dl className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Imported On
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{created_at ? format(parseISO(created_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Last Updated
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{updated_at ? format(parseISO(updated_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inferredMetadata?.issue && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Inferred Metadata
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="font-medium">{inferredMetadata.issue.name}</span>
|
||||||
|
{!isEmpty(inferredMetadata.issue.number) && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
#{inferredMetadata.issue.number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Section */}
|
||||||
|
{props.children && (
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide">
|
||||||
|
Actions
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div>{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RawFileDetails;
|
||||||
53
src/client/components/ComicDetail/TabControls.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { ReactElement, useState } from "react";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
|
export const TabControls = (props): ReactElement => {
|
||||||
|
const { filteredTabs, downloadCount } = props;
|
||||||
|
const [active, setActive] = useState(filteredTabs[0].id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex gap-6" aria-label="Tabs">
|
||||||
|
{filteredTabs.map(({ id, name, icon }) => (
|
||||||
|
<a
|
||||||
|
key={id}
|
||||||
|
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||||
|
active === id
|
||||||
|
? "border-b border-cyan-50 dark:text-slate-200"
|
||||||
|
: "border-b border-transparent"
|
||||||
|
}`}
|
||||||
|
aria-current="page"
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
>
|
||||||
|
{/* Downloads tab and count badge */}
|
||||||
|
<>
|
||||||
|
{id === 6 && !isNil(downloadCount) ? (
|
||||||
|
<span className="inline-flex flex-row">
|
||||||
|
{/* download count */}
|
||||||
|
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-5 h-5">{icon}</span>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filteredTabs.map(({ id, content }) => {
|
||||||
|
return active === id ? content : null;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabControls;
|
||||||
261
src/client/components/ComicDetail/Tabs/ArchiveOperations.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
|
import { DnD } from "../../shared/Draggable/DnD";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import SlidingPane from "react-sliding-pane";
|
||||||
|
import { Canvas } from "../../shared/Canvas";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
LIBRARY_SERVICE_HOST,
|
||||||
|
} from "../../../constants/endpoints";
|
||||||
|
import { useStore } from "../../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
||||||
|
|
||||||
|
interface ArchiveOperationsProps {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement => {
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
|
const { getSocket } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
// sliding panel config
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
// current image
|
||||||
|
const [currentImage, setCurrentImage] = useState<string>("");
|
||||||
|
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
|
||||||
|
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
|
||||||
|
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||||
|
useState(false);
|
||||||
|
const constructImagePaths = (data: string[]): Array<string> => {
|
||||||
|
return data?.map((path: string) =>
|
||||||
|
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen to the uncompression complete event and orchestrate the final payload
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
|
||||||
|
const handleUncompressionComplete = (data: any) => {
|
||||||
|
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||||
|
|
||||||
|
// Cleanup listener on unmount
|
||||||
|
return () => {
|
||||||
|
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||||
|
};
|
||||||
|
}, [getSocket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
if (data.rawFileDetails?.archive?.uncompressed) {
|
||||||
|
const fetchUncompressedArchive = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
|
||||||
|
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
|
||||||
|
},
|
||||||
|
transformResponse: async (responseData) => {
|
||||||
|
const parsedData = JSON.parse(responseData);
|
||||||
|
const paths = parsedData.map((pathObject: any) => {
|
||||||
|
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
|
||||||
|
});
|
||||||
|
const uncompressedArchive = constructImagePaths(paths);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setUncompressedArchive(uncompressedArchive);
|
||||||
|
setShouldRefetchComicBookData(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching uncompressed archive:", error);
|
||||||
|
// Handle error if necessary
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchUncompressedArchive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
setUncompressedArchive([]);
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const analyzeImage = async (imageFilePath: string) => {
|
||||||
|
const response = await axios({
|
||||||
|
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
imageFilePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setImageAnalysisResult(response?.data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: uncompressionResult,
|
||||||
|
refetch,
|
||||||
|
isLoading,
|
||||||
|
isSuccess,
|
||||||
|
} = useQuery({
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: `http://localhost:3000/api/library/uncompressFullArchive`,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
filePath: data.rawFileDetails.filePath,
|
||||||
|
comicObjectId: data._id,
|
||||||
|
options: {
|
||||||
|
type: "full",
|
||||||
|
purpose: "analysis",
|
||||||
|
imageResizeOptions: {
|
||||||
|
baseWidth: 275,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["uncompressedArchive"],
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccess && shouldRefetchComicBookData) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||||
|
setShouldRefetchComicBookData(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sliding panel init
|
||||||
|
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||||
|
imageAnalysis: {
|
||||||
|
content: () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<pre className="text-sm">{currentImage}</pre>
|
||||||
|
{!isEmpty(imageAnalysisResult) ? (
|
||||||
|
<pre className="p-2 mt-3">
|
||||||
|
<Canvas data={imageAnalysisResult} />
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
<pre className="font-hasklig mt-3 text-sm">
|
||||||
|
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// sliding panel handlers
|
||||||
|
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||||
|
setSlidingPanelContentId("imageAnalysis");
|
||||||
|
analyzeImage(imageFilePath);
|
||||||
|
setCurrentImage(imageFilePath);
|
||||||
|
setVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={2}>
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 text-md 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>You can perform several operations on your comic book archive.</p>
|
||||||
|
<p>
|
||||||
|
Uncompressing, re-organizing the individual pages, then
|
||||||
|
re-compressing to a different format, for example.
|
||||||
|
</p>
|
||||||
|
<p>You can also analyze color histograms of pages.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div className="mt-5">
|
||||||
|
{data.rawFileDetails.archive?.uncompressed &&
|
||||||
|
!isEmpty(uncompressedArchive) ? (
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 text-md 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"
|
||||||
|
>
|
||||||
|
This issue is already uncompressed at:
|
||||||
|
<p>
|
||||||
|
<code className="font-hasklig text-sm">
|
||||||
|
{data.rawFileDetails.archive.expandedPath}
|
||||||
|
</code>
|
||||||
|
<div className="">It has {uncompressedArchive?.length} pages</div>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 mt-4">
|
||||||
|
{isEmpty(uncompressedArchive) ? (
|
||||||
|
<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-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
<span className="text-md">Unpack Comic Archive</span>
|
||||||
|
<span className="w-6 h-6">
|
||||||
|
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isEmpty(uncompressedArchive) ? (
|
||||||
|
<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-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
<span className="text-md">Convert to .cbz</span>
|
||||||
|
<span className="w-6 h-6">
|
||||||
|
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mt-10">
|
||||||
|
{!isEmpty(uncompressedArchive) ? (
|
||||||
|
<DnD
|
||||||
|
data={uncompressedArchive}
|
||||||
|
onClickHandler={openImageAnalysisPanel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SlidingPane
|
||||||
|
isOpen={visible}
|
||||||
|
onRequestClose={() => setVisible(false)}
|
||||||
|
title={"Image Analysis"}
|
||||||
|
width={"600px"}
|
||||||
|
>
|
||||||
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
|
</SlidingPane>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArchiveOperations;
|
||||||
66
src/client/components/ComicDetail/Tabs/ComicInfoXML.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { isUndefined } from "lodash";
|
||||||
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
|
export const ComicInfoXML = (data): ReactElement => {
|
||||||
|
const { json } = data;
|
||||||
|
return (
|
||||||
|
<div className="flex md:w-4/5 lg:w-78">
|
||||||
|
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||||
|
<dt>
|
||||||
|
<p className="text-lg">{json.series[0]}</p>
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
published by{" "}
|
||||||
|
<span className="underline">
|
||||||
|
{json.publisher[0]}
|
||||||
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<span className="inline-flex flex-row gap-2">
|
||||||
|
{/* Issue number */}
|
||||||
|
{!isUndefined(json.number) && (
|
||||||
|
<dd className="my-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium 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-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
|
{parseInt(json.number[0], 10)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
<dd className="my-2">
|
||||||
|
{/* Genre */}
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-slate-500 dark:text-slate-900">
|
||||||
|
{json.genre[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<dd className="my-1">
|
||||||
|
{/* Summary */}
|
||||||
|
{!isUndefined(json.summary) && (
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{json.summary[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
{/* Notes */}
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||||
|
{json.notes[0]}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComicInfoXML;
|
||||||
17
src/client/components/ComicDetail/Tabs/VolumeInformation.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import ComicVineDetails from "../ComicVineDetails";
|
||||||
|
|
||||||
|
export const VolumeInformation = (props): ReactElement => {
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={1}>
|
||||||
|
<ComicVineDetails
|
||||||
|
data={data.sourcedMetadata.comicvine}
|
||||||
|
updatedAt={data.updatedAt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeInformation;
|
||||||
77
src/client/components/ComicDetail/TorrentDownloads.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
|
export const TorrentDownloads = (props) => {
|
||||||
|
const { data } = props;
|
||||||
|
console.log(Object.values(data));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.map(({ torrent }) => {
|
||||||
|
return (
|
||||||
|
<dl className="mt-5 dark:text-slate-200 text-slate-600">
|
||||||
|
<dt className="text-lg">{torrent.name}</dt>
|
||||||
|
<p className="text-sm">{torrent.hash}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Added on {dayjs.unix(torrent.added_on).format("ddd, D MMM, YYYY")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="flex gap-2 mt-1">
|
||||||
|
{torrent.progress > 0 ? (
|
||||||
|
<>
|
||||||
|
<progress
|
||||||
|
className="w-80 mt-2 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-slate-300 [&::-webkit-progress-value]:bg-green-400 [&::-moz-progress-bar]:bg-green-400 h-2"
|
||||||
|
value={Math.floor(torrent.progress * 100).toString()}
|
||||||
|
max="100"
|
||||||
|
></progress>
|
||||||
|
|
||||||
|
<span>{Math.floor(torrent.progress * 100)}%</span>
|
||||||
|
|
||||||
|
{/* downloaded/left */}
|
||||||
|
<p className="inline-flex items-center bg-slate-200 text-green-800 dark:text-green-900 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-slate-400">
|
||||||
|
<span className="pr-1">
|
||||||
|
<i className="icon-[solar--arrow-to-down-left-outline] h-4 w-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-md">
|
||||||
|
{prettyBytes(torrent.downloaded)}
|
||||||
|
</span>
|
||||||
|
{/* uploaded */}
|
||||||
|
<span className="pr-1 text-orange-800 dark:text-orange-900 ml-2">
|
||||||
|
<i className="icon-[solar--arrow-to-top-left-outline] h-4 w-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-md text-orange-800 dark:text-orange-900">
|
||||||
|
{prettyBytes(torrent.uploaded)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 mt-2">
|
||||||
|
{/* Peers */}
|
||||||
|
<span className="inline-flex items-center bg-slate-200 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">
|
||||||
|
<i className="icon-[solar--station-minimalistic-line-duotone] h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-md text-slate-900">
|
||||||
|
{torrent.trackers_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<span className="inline-flex items-center bg-slate-200 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--mirror-right-bold-duotone] h-4 w-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-md text-slate-900">
|
||||||
|
{prettyBytes(torrent.total_size)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TorrentDownloads;
|
||||||
202
src/client/components/ComicDetail/TorrentSearchPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import {
|
||||||
|
PROWLARR_SERVICE_BASE_URI,
|
||||||
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
|
} from "../../constants/endpoints";
|
||||||
|
import { isEmpty, isNil } from "lodash";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
|
export const TorrentSearchPanel = (props) => {
|
||||||
|
const { issueName, comicObjectId } = props;
|
||||||
|
// Initialize searchTerm with issueName from props
|
||||||
|
const [searchTerm, setSearchTerm] = useState({ issueName });
|
||||||
|
const [torrentToDownload, setTorrentToDownload] = useState("");
|
||||||
|
|
||||||
|
const { data, isSuccess, isLoading } = useQuery({
|
||||||
|
queryKey: ["searchResults", searchTerm.issueName],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await axios({
|
||||||
|
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
prowlarrQuery: {
|
||||||
|
port: "9696",
|
||||||
|
apiKey: "38c2656e8f5d4790962037b8c4416a8f",
|
||||||
|
offset: 0,
|
||||||
|
categories: [7030],
|
||||||
|
query: searchTerm.issueName,
|
||||||
|
host: "localhost",
|
||||||
|
limit: 100,
|
||||||
|
type: "search",
|
||||||
|
indexerIds: [2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !isNil(searchTerm.issueName) && searchTerm.issueName.trim() !== "", // Make sure searchTerm is not empty
|
||||||
|
});
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (newTorrent) =>
|
||||||
|
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
console.log(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const searchIndexer = (values) => {
|
||||||
|
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
|
||||||
|
};
|
||||||
|
const downloadTorrent = (evt) => {
|
||||||
|
const newTorrent = {
|
||||||
|
comicObjectId,
|
||||||
|
torrentToDownload: evt,
|
||||||
|
};
|
||||||
|
mutation.mutate(newTorrent);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Form
|
||||||
|
onSubmit={searchIndexer}
|
||||||
|
initialValues={searchTerm}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Field name="issueName">
|
||||||
|
{({ input, meta }) => (
|
||||||
|
<div className="max-w-fit">
|
||||||
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||||
|
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||||
|
{/* Icon placeholder */}
|
||||||
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
type="text"
|
||||||
|
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||||
|
placeholder="Enter a search term"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="sm:mt-0 min-w-fit 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"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
Search Indexer
|
||||||
|
<div className="h-5 w-5 ml-1">
|
||||||
|
<i className="h-6 w-6 icon-[solar--magnet-bold-duotone]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm 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>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{!isEmpty(data?.data) ? (
|
||||||
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
|
||||||
|
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Indexer
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||||
|
{data?.data.map((result, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
|
||||||
|
<p>{ellipsize(result.fileName, 90)}</p>
|
||||||
|
{/* Seeders/Leechers */}
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<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--archive-up-minimlistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{result.seeders} seeders
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<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--archive-down-minimlistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{result.leechers} leechers
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/* 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(result.size)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<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--documents-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{result.files} files
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
|
||||||
|
{result.indexer}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
|
||||||
|
<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={() => downloadTorrent(result.downloadUrl)}
|
||||||
|
>
|
||||||
|
<span className="text-xs">Download</span>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TorrentSearchPanel;
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Form, Field } from "react-final-form";
|
|
||||||
import Collapsible from "react-collapsible";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for accepting ComicVine search parameters
|
|
||||||
*
|
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* const age = 21
|
|
||||||
* const name = 'Jitendra Nirnejak'
|
|
||||||
* return (
|
|
||||||
* <User age={age} name={name} />
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
export const ComicVineSearchForm = () => {
|
|
||||||
const onSubmit = () => {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const validate = () => {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyForm = () => (
|
|
||||||
<Form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
validate={validate}
|
|
||||||
render={({ handleSubmit }) => (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<span className="field is-normal">
|
|
||||||
<label className="label">Issue Details</label>
|
|
||||||
</span>
|
|
||||||
<div className="field is-horizontal">
|
|
||||||
<div className="field-body">
|
|
||||||
<div className="field">
|
|
||||||
<Field name="issueName">
|
|
||||||
{(props) => (
|
|
||||||
<p className="control is-expanded has-icons-left">
|
|
||||||
<input
|
|
||||||
{...props.input}
|
|
||||||
className="input is-normal"
|
|
||||||
placeholder="Type the issue name"
|
|
||||||
/>
|
|
||||||
<span className="icon is-small is-left">
|
|
||||||
<i className="fas fa-journal-whills"></i>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
|
||||||
<Field name="issueNumber">
|
|
||||||
{(props) => (
|
|
||||||
<p className="control is-expanded has-icons-left">
|
|
||||||
<input
|
|
||||||
{...props.input}
|
|
||||||
className="input is-normal"
|
|
||||||
placeholder="Type the issue number"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-hand-sparkles"></i>
|
|
||||||
</span>
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
trigger={"Match Manually"}
|
|
||||||
triggerTagName="a"
|
|
||||||
triggerClassName={"is-size-6"}
|
|
||||||
triggerOpenedClassName={"is-size-6"}
|
|
||||||
>
|
|
||||||
<MyForm />
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ComicVineSearchForm;
|
|
||||||
@@ -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);
|
|
||||||
83
src/client/components/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
|
||||||
|
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: {
|
||||||
|
wanted: { $exists: false },
|
||||||
|
},
|
||||||
|
comicStatus: "recent",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["recentComics"],
|
||||||
|
});
|
||||||
|
// Wanted Comics
|
||||||
|
const { data: wantedComics } = useQuery({
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
paginationOptions: {
|
||||||
|
page: 0,
|
||||||
|
limit: 5,
|
||||||
|
sort: { updatedAt: "-1" },
|
||||||
|
},
|
||||||
|
predicate: {
|
||||||
|
wanted: { $exists: true, $ne: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["wantedComics"],
|
||||||
|
});
|
||||||
|
const { data: volumeGroups } = useQuery({
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
queryKey: ["volumeGroups"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statistics } = useQuery({
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
queryKey: ["libraryStatistics"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-full">
|
||||||
|
<PullList />
|
||||||
|
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||||
|
{/* Wanted comics */}
|
||||||
|
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||||
|
{/* Library Statistics */}
|
||||||
|
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||||
|
{/* Volume groups */}
|
||||||
|
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
103
src/client/components/Dashboard/LibraryStatistics.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { ReactElement, useEffect } from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { isEmpty, isUndefined, map } from "lodash";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
|
||||||
|
export const LibraryStatistics = (
|
||||||
|
props: ILibraryStatisticsProps,
|
||||||
|
): ReactElement => {
|
||||||
|
const { stats } = props;
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Header
|
||||||
|
headerContent="Your Library In Numbers"
|
||||||
|
subHeaderContent={
|
||||||
|
<span className="text-md">A brief snapshot of your library.</span>
|
||||||
|
}
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex flex-row gap-5">
|
||||||
|
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||||
|
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||||
|
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||||
|
{props.stats.totalDocuments} files
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<span className="text-2xl text-green-600">
|
||||||
|
{props.stats.comicDirectorySize &&
|
||||||
|
prettyBytes(props.stats.comicDirectorySize)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/* comicinfo and comicvine tagged issues */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].issues) && (
|
||||||
|
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||||
|
<span className="text-xl">
|
||||||
|
{props.stats.statistics[0].issues.length}
|
||||||
|
</span>{" "}
|
||||||
|
tagged with ComicVine
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
|
||||||
|
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||||
|
<span className="text-xl">
|
||||||
|
{props.stats.statistics[0].issuesWithComicInfoXML.length}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||||
|
with ComicInfo.xml
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||||
|
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
|
||||||
|
>
|
||||||
|
{fileType.data.length} {fileType._id}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* file types */}
|
||||||
|
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||||
|
{/* publisher with most issues */}
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(
|
||||||
|
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||||
|
) && (
|
||||||
|
<>
|
||||||
|
<span className="">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0]._id
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{" has the most issues "}
|
||||||
|
<span className="">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0].count
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryStatistics;
|
||||||
180
src/client/components/Dashboard/PullList.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { ReactElement, useState, useEffect } from "react";
|
||||||
|
import { map } from "lodash";
|
||||||
|
import Card from "../shared/Carda";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
import { importToDB } from "../../actions/fileops.actions";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import rateLimiter from "axios-rate-limit";
|
||||||
|
import { setupCache } from "axios-cache-interceptor";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import "keen-slider/keen-slider.min.css";
|
||||||
|
import { useKeenSlider } from "keen-slider/react";
|
||||||
|
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||||
|
import { Field, Form } from "react-final-form";
|
||||||
|
import DatePickerDialog from "../shared/DatePicker";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
type PullListProps = {
|
||||||
|
issues: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const http = rateLimiter(axios.create(), {
|
||||||
|
maxRequests: 1,
|
||||||
|
perMilliseconds: 1000,
|
||||||
|
maxRPS: 1,
|
||||||
|
});
|
||||||
|
const cachedAxios = setupCache(axios);
|
||||||
|
export const PullList = (): ReactElement => {
|
||||||
|
// datepicker
|
||||||
|
const date = new Date();
|
||||||
|
const [inputValue, setInputValue] = useState<string>(
|
||||||
|
format(date, "yyyy/M/dd"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Responsive slides per view
|
||||||
|
const [slidesPerView, setSlidesPerView] = useState(1);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// keen slider
|
||||||
|
const [sliderRef, instanceRef] = useKeenSlider({
|
||||||
|
loop: true,
|
||||||
|
mode: "free-snap",
|
||||||
|
slides: {
|
||||||
|
perView: slidesPerView,
|
||||||
|
spacing: 15,
|
||||||
|
},
|
||||||
|
slideChanged() {
|
||||||
|
console.log("slide changed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update slider when slidesPerView changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceRef.current) {
|
||||||
|
instanceRef.current.update({
|
||||||
|
slides: {
|
||||||
|
perView: slidesPerView,
|
||||||
|
spacing: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [slidesPerView, instanceRef]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: pullList,
|
||||||
|
refetch,
|
||||||
|
isSuccess,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryFn: async (): any =>
|
||||||
|
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||||
|
method: "get",
|
||||||
|
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
|
||||||
|
}),
|
||||||
|
queryKey: ["pullList", inputValue],
|
||||||
|
});
|
||||||
|
const addToLibrary = (sourceName: string, locgMetadata) =>
|
||||||
|
importToDB(sourceName, { locg: locgMetadata });
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
// sliderRef.slickNext();
|
||||||
|
};
|
||||||
|
const previous = () => {
|
||||||
|
// sliderRef.slickPrev();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content">
|
||||||
|
<div className="mx-auto">
|
||||||
|
<Header
|
||||||
|
headerContent="Discover"
|
||||||
|
subHeaderContent={
|
||||||
|
<span className="text-md">
|
||||||
|
Pull List aggregated for the week from{" "}
|
||||||
|
<span className="underline">
|
||||||
|
<a href="https://leagueofcomicgeeks.com">
|
||||||
|
League Of Comic Geeks
|
||||||
|
</a>
|
||||||
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
link="/pull-list/all/"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-row gap-5 mb-3">
|
||||||
|
{/* select week */}
|
||||||
|
<div className="flex flex-row gap-4 my-3">
|
||||||
|
<Form
|
||||||
|
onSubmit={() => {}}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* week selection for pull list */}
|
||||||
|
<DatePickerDialog
|
||||||
|
inputValue={inputValue}
|
||||||
|
setter={setInputValue}
|
||||||
|
/>
|
||||||
|
{inputValue && (
|
||||||
|
<div className="text-sm">
|
||||||
|
Showing pull list for{" "}
|
||||||
|
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
{inputValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuccess && !isLoading && (
|
||||||
|
<div ref={sliderRef} className="keen-slider">
|
||||||
|
{map(pullList?.data.result, (issue, idx) => {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="keen-slider__slide">
|
||||||
|
<Card
|
||||||
|
orientation={"vertical-2"}
|
||||||
|
imageUrl={issue.coverImageUrl}
|
||||||
|
hasDetails
|
||||||
|
title={ellipsize(issue.issueName, 25)}
|
||||||
|
>
|
||||||
|
<div className="px-1">
|
||||||
|
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
{issue.publicationDate}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row justify-end">
|
||||||
|
<button
|
||||||
|
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => addToLibrary("locg", issue)}
|
||||||
|
>
|
||||||
|
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? <div>Loading...</div> : null}
|
||||||
|
{isError ? (
|
||||||
|
<div>An error occurred while retrieving the pull list.</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PullList;
|
||||||
133
src/client/components/Dashboard/RecentlyImported.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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 {
|
||||||
|
determineCoverFile,
|
||||||
|
determineExternalMetadata,
|
||||||
|
} from "../../shared/utils/metadata.utils";
|
||||||
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
|
||||||
|
type RecentlyImportedProps = {
|
||||||
|
comics: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecentlyImported = (
|
||||||
|
comics: RecentlyImportedProps,
|
||||||
|
): ReactElement => {
|
||||||
|
console.log(comics);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto" style={{ maxWidth: '1400px' }}>
|
||||||
|
<Header
|
||||||
|
headerContent="Recently Imported"
|
||||||
|
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 gap-6 mt-3">
|
||||||
|
{comics?.comics.map(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
_id,
|
||||||
|
rawFileDetails,
|
||||||
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
|
inferredMetadata,
|
||||||
|
wanted: { source } = {},
|
||||||
|
},
|
||||||
|
idx,
|
||||||
|
) => {
|
||||||
|
const { issueName, url } = determineCoverFile({
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
});
|
||||||
|
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
{/* Uncompressed status */}
|
||||||
|
{rawFileDetails?.archive?.uncompressed ? (
|
||||||
|
<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--bookmark-bold-duotone] w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-1 mt-2 pb-1">
|
||||||
|
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
||||||
|
{/* ComicInfo.xml presence */}
|
||||||
|
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||||
|
<div mt-1>
|
||||||
|
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
src/client/components/Dashboard/VolumeGroups.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { map, unionBy } from "lodash";
|
||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import Card from "../shared/Carda";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
|
||||||
|
export const VolumeGroups = (props): ReactElement => {
|
||||||
|
// 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>
|
||||||
|
<Header
|
||||||
|
headerContent="Volumes"
|
||||||
|
subHeaderContent="Based on ComicVine Volume information"
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
link={"/volumes"}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||||
|
{map(deduplicatedGroups, (data) => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto" key={data._id}>
|
||||||
|
<Card
|
||||||
|
orientation="vertical-2"
|
||||||
|
key={data._id}
|
||||||
|
imageUrl={data.volumes.image.small_url}
|
||||||
|
hasDetails
|
||||||
|
>
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link to={`/volume/details/${data._id}`}>
|
||||||
|
{ellipsize(data.volumes.name, 48)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{/* issue count */}
|
||||||
|
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{data.volumes.count_of_issues} issues
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
||||||
|
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
||||||
|
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeGroups;
|
||||||
142
src/client/components/Dashboard/WantedComicsList.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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 { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
import Header from "../shared/Header";
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
|
||||||
|
type WantedComicsListProps = {
|
||||||
|
comics: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WantedComicsList = ({
|
||||||
|
comics,
|
||||||
|
}: WantedComicsListProps): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// embla carousel
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
loop: false,
|
||||||
|
align: "start",
|
||||||
|
containScroll: "trimSnaps",
|
||||||
|
slidesToScroll: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header
|
||||||
|
headerContent="Wanted Comics"
|
||||||
|
subHeaderContent="Comics marked as wanted from various sources"
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
link={"/wanted"}
|
||||||
|
/>
|
||||||
|
<div className="overflow-hidden -mr-4 sm:-mr-8 lg:-mr-16 xl:-mr-20 2xl:-mr-24 mt-3">
|
||||||
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
|
<div className="flex">
|
||||||
|
{map(
|
||||||
|
comics,
|
||||||
|
(
|
||||||
|
{
|
||||||
|
_id,
|
||||||
|
rawFileDetails,
|
||||||
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
|
wanted,
|
||||||
|
},
|
||||||
|
idx,
|
||||||
|
) => {
|
||||||
|
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||||
|
const consolidatedComicMetadata = {
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
issueName,
|
||||||
|
url,
|
||||||
|
publisher = null,
|
||||||
|
} = determineCoverFile(consolidatedComicMetadata);
|
||||||
|
const titleElement = (
|
||||||
|
<Link to={"/comic/details/" + _id}>
|
||||||
|
{ellipsize(issueName, 20)}
|
||||||
|
<p>{publisher}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
orientation={"vertical-2"}
|
||||||
|
imageUrl={url}
|
||||||
|
hasDetails
|
||||||
|
title={issueName ? titleElement : <span>No Name</span>}
|
||||||
|
>
|
||||||
|
<div className="pb-1">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{/* Issue type */}
|
||||||
|
{isComicBookMetadataAvailable &&
|
||||||
|
!isNil(detectIssueTypes(comicvine.description)) ? (
|
||||||
|
<div className="my-2">
|
||||||
|
<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--book-2-line-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{
|
||||||
|
detectIssueTypes(comicvine.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* issues marked as wanted, part of this volume */}
|
||||||
|
{wanted?.markEntireVolumeWanted ? (
|
||||||
|
<div className="text-sm">sagla volume pahije</div>
|
||||||
|
) : (
|
||||||
|
<div className="my-2">
|
||||||
|
<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--documents-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{wanted.issues.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* comicVine metadata presence */}
|
||||||
|
{isComicBookMetadataAvailable && (
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
|
alt={"ComicVine metadata detected."}
|
||||||
|
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isEmpty(locg) && (
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/locglogo.svg"
|
||||||
|
className="w-7 h-7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
interface ZeroStateProps {
|
interface ZeroStateProps {
|
||||||
header: string;
|
header: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<article className="message is-info">
|
<article className="">
|
||||||
<div className="message-body">
|
<div className="">
|
||||||
<p>{ props.header }</p>
|
<p>{props.header}</p>
|
||||||
{ props.message }
|
{props.message}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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 };
|
|
||||||
374
src/client/components/Import/Import.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { ReactElement, useCallback, useEffect, useRef } 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, getSocket, setQueryClientRef } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
importJobQueue: state.importJobQueue,
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
setQueryClientRef: state.setQueryClientRef,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousResultCountRef = useRef<number>(0);
|
||||||
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Set the queryClient reference in the store so socket events can use it
|
||||||
|
useEffect(() => {
|
||||||
|
setQueryClientRef({ current: queryClient });
|
||||||
|
}, [queryClient, setQueryClientRef]);
|
||||||
|
|
||||||
|
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, refetch } = useQuery({
|
||||||
|
queryKey: ["allImportJobResults"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios({
|
||||||
|
method: "GET",
|
||||||
|
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
|
||||||
|
params: {
|
||||||
|
_t: Date.now(), // Cache buster
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the result count
|
||||||
|
if (response.data?.length) {
|
||||||
|
previousResultCountRef.current = response.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 0, // Always consider data stale
|
||||||
|
gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
|
||||||
|
// Poll every 5 seconds when import is running
|
||||||
|
refetchInterval: importJobQueue.status === "running" || importJobQueue.status === "paused" ? 5000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for import queue drained event to refresh the table
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
|
||||||
|
const handleQueueDrained = () => {
|
||||||
|
const initialCount = previousResultCountRef.current;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 20; // Poll for up to 20 seconds
|
||||||
|
|
||||||
|
// Clear any existing polling interval
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every second until we see new data or hit max attempts
|
||||||
|
pollingIntervalRef.current = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const result = await refetch();
|
||||||
|
const newCount = result.data?.data?.length || 0;
|
||||||
|
|
||||||
|
if (newCount > initialCount) {
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
} else if (attempts >= maxAttempts) {
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [getSocket, queryClient, refetch]);
|
||||||
|
|
||||||
|
const toggleQueue = (queueAction: string, queueStatus: string) => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
socket.emit(
|
||||||
|
"call",
|
||||||
|
"socket.setQueueStatus",
|
||||||
|
{
|
||||||
|
queueAction,
|
||||||
|
queueStatus,
|
||||||
|
},
|
||||||
|
(data: any) => 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 w-fit 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: any, id: number) => {
|
||||||
|
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;
|
||||||
318
src/client/components/Library/Library.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
|
import T2Table from "../shared/T2Table";
|
||||||
|
import SearchBar from "../Library/SearchBar";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import {
|
||||||
|
useQuery,
|
||||||
|
keepPreviousData,
|
||||||
|
useQueryClient,
|
||||||
|
} 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);
|
||||||
|
const [searchQuery, setSearchQuery] = useState({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: offset,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||||
|
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||||
|
*/
|
||||||
|
const fetchIssues = async (searchQuery) => {
|
||||||
|
const { pagination, query, type } = searchQuery;
|
||||||
|
return await axios({
|
||||||
|
method: "POST",
|
||||||
|
url: "http://localhost:3000/api/search/searchIssue",
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
pagination,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchIssues = (e) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {
|
||||||
|
volumeName: e.search,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "volumeName",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ["comics", offset, searchQuery],
|
||||||
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
|
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 w-fit 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()) ? (
|
||||||
|
<span className="inline-flex items-center bg-slate-300 dark:bg-slate-500 text-xs font-medium text-slate-700 dark:text-slate-200 px-3 py-1 rounded-md shadow-sm whitespace-nowrap ml-3 my-3">
|
||||||
|
<i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" />
|
||||||
|
{format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Downloads",
|
||||||
|
accessorKey: "_source.acquisition",
|
||||||
|
cell: (info) => (
|
||||||
|
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||||
|
{/* DC++ Downloads */}
|
||||||
|
{info.getValue().directconnect?.downloads?.length > 0 ? (
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<i className="icon-[solar--folder-path-connect-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
|
<span>
|
||||||
|
DC++: {info.getValue().directconnect.downloads.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Torrent Downloads */}
|
||||||
|
{info.getValue().torrent.length > 0 ? (
|
||||||
|
<span className="inline-flex items-center whitespace-nowrap bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<i className="icon-[solar--magnet-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
Torrent: {info.getValue().torrent.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</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) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// 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>
|
||||||
|
<T2Table
|
||||||
|
totalPages={searchResults.hits.total.value}
|
||||||
|
columns={columns}
|
||||||
|
sourceData={searchResults?.hits.hits}
|
||||||
|
rowClickHandler={navigateToComicDetail}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||||
|
</T2Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-screen-xl mt-5">
|
||||||
|
<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>
|
||||||
|
</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;
|
||||||
48
src/client/components/Library/SearchBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { ReactElement } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export const SearchBar = (props): ReactElement => {
|
||||||
|
const { searchHandler } = props;
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={searchHandler}
|
||||||
|
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;
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { RootState } from "threetwo-ui-typings";
|
|
||||||
|
|
||||||
const Navbar: React.FunctionComponent = (props) => {
|
|
||||||
const socketConnection = useSelector((state: RootState) => state.fileOps);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="navbar ">
|
|
||||||
<div className="navbar-brand">
|
|
||||||
<a className="navbar-item" href="http://bulma.io">
|
|
||||||
<img
|
|
||||||
src="http://bulma.io/images/bulma-logo.png"
|
|
||||||
alt="Bulma: a modern CSS framework based on Flexbox"
|
|
||||||
width="112"
|
|
||||||
height="28"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a className="navbar-item is-hidden-desktop">
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-github"></i>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a className="navbar-item is-hidden-desktop">
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-twitter"></i>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="navbar-burger burger" data-target="navMenubd-example">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="navMenubd-example" className="navbar-menu">
|
|
||||||
<div className="navbar-start">
|
|
||||||
<Link to="/" className="navbar-item">
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/import" className="navbar-item">
|
|
||||||
Import
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a
|
|
||||||
className="navbar-link is-active"
|
|
||||||
href="/documentation/overview/start/"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<div className="navbar-dropdown ">
|
|
||||||
<a className="navbar-item " href="/documentation/overview/start/">
|
|
||||||
Overview
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
className="navbar-item is-active"
|
|
||||||
href="http://bulma.io/documentation/components/breadcrumb/"
|
|
||||||
>
|
|
||||||
Components
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<hr className="navbar-divider" />
|
|
||||||
<div className="navbar-item">
|
|
||||||
<div>
|
|
||||||
<p className="is-size-6-desktop">
|
|
||||||
<strong className="has-text-info">0.5.1</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<small>
|
|
||||||
<a className="bd-view-all-versions" href="/versions">
|
|
||||||
View all versions
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="navbar-item has-dropdown is-hoverable is-mega">
|
|
||||||
<div className="navbar-link flex">Blog</div>
|
|
||||||
<div id="blogDropdown" className="navbar-dropdown">
|
|
||||||
<div className="container is-fluid">
|
|
||||||
<div className="columns">
|
|
||||||
<div className="column">
|
|
||||||
<h1 className="title is-6 is-mega-menu-title">
|
|
||||||
Sub Menu Title
|
|
||||||
</h1>
|
|
||||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
|
||||||
<div className="navbar-content">
|
|
||||||
<p>
|
|
||||||
<small className="has-text-info">03 Aug 2017</small>
|
|
||||||
</p>
|
|
||||||
<p>New feature: list of tags</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
|
||||||
<div className="navbar-content">
|
|
||||||
<p>
|
|
||||||
<small className="has-text-info">03 Aug 2017</small>
|
|
||||||
</p>
|
|
||||||
<p>New feature: list of tags</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
|
||||||
<div className="navbar-content">
|
|
||||||
<p>
|
|
||||||
<small className="has-text-info">03 Aug 2017</small>
|
|
||||||
</p>
|
|
||||||
<p>New feature: list of tags</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<h1 className="title is-6 is-mega-menu-title">
|
|
||||||
Sub Menu Title
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<a
|
|
||||||
className="navbar-item "
|
|
||||||
href="http://bulma.io/documentation/columns/basics/"
|
|
||||||
>
|
|
||||||
Columns
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<h1 className="title is-6 is-mega-menu-title">
|
|
||||||
Sub Menu Title
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
|
||||||
<div className="navbar-content">
|
|
||||||
<p>
|
|
||||||
<small className="has-text-info">03 Aug 2017</small>
|
|
||||||
</p>
|
|
||||||
<p>New feature: list of tags</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<h1 className="title is-6 is-mega-menu-title">
|
|
||||||
Sub Menu Title
|
|
||||||
</h1>
|
|
||||||
<a
|
|
||||||
className="navbar-item "
|
|
||||||
href="/documentation/overview/start/"
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="navbar-divider" />
|
|
||||||
<div className="navbar-item">
|
|
||||||
<div className="navbar-content">
|
|
||||||
<div className="level is-mobile">
|
|
||||||
<div className="level-left">
|
|
||||||
<div className="level-item">
|
|
||||||
<strong>Stay up to date!</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="level-right">
|
|
||||||
<div className="level-item">
|
|
||||||
<a
|
|
||||||
className="button bd-is-rss is-small"
|
|
||||||
href="http://bulma.io/atom.xml"
|
|
||||||
>
|
|
||||||
<span className="icon is-small">
|
|
||||||
<i className="fa fa-rss"></i>
|
|
||||||
</span>
|
|
||||||
<span>Subscribe</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="navbar-end">
|
|
||||||
<a className="navbar-item is-hidden-desktop-only"></a>
|
|
||||||
<div className="navbar-item">
|
|
||||||
<div className="field is-grouped">
|
|
||||||
<p className="control">
|
|
||||||
{socketConnection.socketConnected ? (
|
|
||||||
<span className="icon is-small has-text-success">
|
|
||||||
<i className="fas fa-plug"></i>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="control">Settings</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Navbar;
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
488
src/client/components/Search/Search.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import React, { ReactElement, useState } from "react";
|
||||||
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
|
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||||
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import Card from "../shared/Carda";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { convert } from "html-to-text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "../../shared/utils/i18n.util";
|
||||||
|
import PopoverButton from "../shared/PopoverButton";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
COMICVINE_SERVICE_URI,
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
} from "../../constants/endpoints";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface ISearchProps {}
|
||||||
|
|
||||||
|
export const Search = ({}: ISearchProps): ReactElement => {
|
||||||
|
const formData = {
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const [comicVineMetadata, setComicVineMetadata] = useState({});
|
||||||
|
const [selectedResource, setSelectedResource] = useState("volume");
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleResourceChange = (value) => {
|
||||||
|
setSelectedResource(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate,
|
||||||
|
data: comicVineSearchResults,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = useMutation({
|
||||||
|
mutationFn: async (data: { search: string; resource: string }) => {
|
||||||
|
const { search, resource } = data;
|
||||||
|
return await axios({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/search`,
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
|
||||||
|
query: search,
|
||||||
|
format: "json",
|
||||||
|
limit: "10",
|
||||||
|
offset: "0",
|
||||||
|
field_list:
|
||||||
|
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
|
||||||
|
resources: resource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// add to library
|
||||||
|
const { data: additionResult, mutate: addToWantedList } = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
source,
|
||||||
|
comicObject,
|
||||||
|
markEntireVolumeWanted,
|
||||||
|
resourceType,
|
||||||
|
}) => {
|
||||||
|
let volumeInformation = {};
|
||||||
|
let issues = [];
|
||||||
|
switch (resourceType) {
|
||||||
|
case "issue":
|
||||||
|
const { id, api_detail_url, image, cover_date, issue_number } =
|
||||||
|
comicObject;
|
||||||
|
// Add issue metadata
|
||||||
|
issues.push({
|
||||||
|
id,
|
||||||
|
url: api_detail_url,
|
||||||
|
image,
|
||||||
|
coverDate: cover_date,
|
||||||
|
issueNumber: issue_number,
|
||||||
|
});
|
||||||
|
console.log(issues);
|
||||||
|
// Get volume metadata from CV
|
||||||
|
const response = await axios({
|
||||||
|
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
volumeURI: comicObject.volume.api_detail_url,
|
||||||
|
fieldList:
|
||||||
|
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// set volume metadata key
|
||||||
|
volumeInformation = response.data?.results;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "volume":
|
||||||
|
const {
|
||||||
|
id: volumeId,
|
||||||
|
api_detail_url: apiUrl,
|
||||||
|
image: volumeImage,
|
||||||
|
name,
|
||||||
|
publisher,
|
||||||
|
} = comicObject;
|
||||||
|
volumeInformation = {
|
||||||
|
id: volumeId,
|
||||||
|
url: apiUrl,
|
||||||
|
image: volumeImage,
|
||||||
|
name,
|
||||||
|
publisher,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log("Invalid resource type.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Add to wanted list
|
||||||
|
return await axios({
|
||||||
|
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
importType: "new",
|
||||||
|
payload: {
|
||||||
|
importStatus: {
|
||||||
|
isImported: false, // wanted, but not acquired yet.
|
||||||
|
tagged: false,
|
||||||
|
matchedResult: {
|
||||||
|
score: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wanted: {
|
||||||
|
source,
|
||||||
|
markEntireVolumeWanted,
|
||||||
|
issues,
|
||||||
|
volume: volumeInformation,
|
||||||
|
},
|
||||||
|
sourcedMetadata: { comicvine: volumeInformation },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToLibrary = (sourceName: string, comicData) =>
|
||||||
|
setComicVineMetadata({ sourceName, comicData });
|
||||||
|
|
||||||
|
const createDescriptionMarkup = (html) => {
|
||||||
|
return { __html: html };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values) => {
|
||||||
|
const formData = { ...values, resource: selectedResource };
|
||||||
|
try {
|
||||||
|
mutate(formData);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
|
<div className="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">
|
||||||
|
Search
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse your comic book collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
<Form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initialValues={{
|
||||||
|
...formData,
|
||||||
|
}}
|
||||||
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Field name="search">
|
||||||
|
{({ input, meta }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</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>
|
||||||
|
{/* resource type selection: volume, issue etc. */}
|
||||||
|
<div className="flex flex-row gap-3 mt-4">
|
||||||
|
<Field name="resource" type="radio" value="volume">
|
||||||
|
{({ input: volumesInput, meta }) => (
|
||||||
|
<div className="w-fit rounded-xl">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
{...volumesInput}
|
||||||
|
type="radio"
|
||||||
|
id="volume"
|
||||||
|
checked={selectedResource === "volume"}
|
||||||
|
onChange={() => handleResourceChange("volume")}
|
||||||
|
className="peer hidden"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="volume"
|
||||||
|
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||||
|
>
|
||||||
|
Volumes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field name="resource" type="radio" value="issue">
|
||||||
|
{({ input: issuesInput, meta }) => (
|
||||||
|
<div className="w-fit rounded-xl">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
{...issuesInput}
|
||||||
|
type="radio"
|
||||||
|
id="issue"
|
||||||
|
checked={selectedResource === "issue"}
|
||||||
|
onChange={() => handleResourceChange("issue")}
|
||||||
|
className="peer hidden"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="issue"
|
||||||
|
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||||
|
>
|
||||||
|
Issues
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isPending && (
|
||||||
|
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
Loading results...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||||
|
<div className="mx-auto w-full sm:w-[90vw] md:w-[80vw] lg:w-[70vw] max-w-6xl px-4 py-6">
|
||||||
|
{comicVineSearchResults.data.results.map((result) => {
|
||||||
|
return result.resource_type === "issue" ? (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
|
||||||
|
>
|
||||||
|
{/* IMAGE */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Card
|
||||||
|
orientation="cover-only"
|
||||||
|
imageUrl={result.image.small_url}
|
||||||
|
hasDetails={false}
|
||||||
|
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT-SIDE CONTENT */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* TITLE */}
|
||||||
|
<div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
|
||||||
|
{result.volume?.name || <span>No Name</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SUBMETA */}
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{/* Cover Date Token */}
|
||||||
|
{result.cover_date && (
|
||||||
|
<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--calendar-bold-duotone] w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-900">
|
||||||
|
{dayjs(result.cover_date).format("MMM YYYY")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ID Token */}
|
||||||
|
<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-bold-duotone] w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-900">
|
||||||
|
{result.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LINK */}
|
||||||
|
<a
|
||||||
|
href={result.api_detail_url}
|
||||||
|
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
|
||||||
|
>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
{result.description && (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
|
||||||
|
{ellipsize(
|
||||||
|
convert(result.description ?? "", {
|
||||||
|
baseElements: { selectors: ["p", "div"] },
|
||||||
|
}),
|
||||||
|
300,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA BUTTON */}
|
||||||
|
{result.volume.name && (
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
|
<PopoverButton
|
||||||
|
content={`This will add ${result?.volume?.name} to your wanted list.`}
|
||||||
|
clickHandler={() =>
|
||||||
|
addToWantedList({
|
||||||
|
source: "comicvine",
|
||||||
|
comicObject: result,
|
||||||
|
markEntireVolumeWanted: false,
|
||||||
|
resourceType: "issue",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
result.resource_type === "volume" && (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
|
||||||
|
>
|
||||||
|
{/* LEFT COLUMN: COVER */}
|
||||||
|
<Card
|
||||||
|
orientation="cover-only"
|
||||||
|
imageUrl={result.image.small_url}
|
||||||
|
hasDetails={false}
|
||||||
|
cardContainerStyle={{
|
||||||
|
width: "120px",
|
||||||
|
maxWidth: "150px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* TITLE */}
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{result.name || <span>No Name</span>}
|
||||||
|
{result.start_year && <> ({result.start_year})</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOKENS */}
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{/* ISSUE COUNT */}
|
||||||
|
{result.count_of_issues && (
|
||||||
|
<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--documents-minimalistic-bold-duotone] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("issueWithCount", {
|
||||||
|
count: result.count_of_issues,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FORMAT DETECTED */}
|
||||||
|
{result.description &&
|
||||||
|
!isEmpty(detectIssueTypes(result.description)) && (
|
||||||
|
<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--book-2-line-duotone] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
detectIssueTypes(result.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ID */}
|
||||||
|
<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-bold-duotone] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span>{result.id}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LINK */}
|
||||||
|
<a
|
||||||
|
href={result.api_detail_url}
|
||||||
|
className="text-sm text-blue-500 underline mt-2 break-all"
|
||||||
|
>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
{result.description && (
|
||||||
|
<p className="text-sm mt-2 text-slate-700 dark:text-slate-200 break-words line-clamp-3">
|
||||||
|
{ellipsize(
|
||||||
|
convert(result.description, {
|
||||||
|
baseElements: { selectors: ["p", "div"] },
|
||||||
|
}),
|
||||||
|
320,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result.name ? (
|
||||||
|
<div className="mt-4 justify-self-end">
|
||||||
|
<PopoverButton
|
||||||
|
content={`This will add ${result.count_of_issues} issues your wanted list.`}
|
||||||
|
clickHandler={() =>
|
||||||
|
addToWantedList({
|
||||||
|
source: "comicvine",
|
||||||
|
comicObject: result,
|
||||||
|
markEntireVolumeWanted: false,
|
||||||
|
resourceType: "issue",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
<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> Search the ComicVine database</p>
|
||||||
|
<p>
|
||||||
|
Note that you need an instance of AirDC++ already running to
|
||||||
|
use this form to connect to it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,173 @@
|
|||||||
|
import React, { ReactElement, useState } 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 axios from "axios";
|
||||||
|
import { produce } from "immer";
|
||||||
|
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||||
|
|
||||||
|
export const AirDCPPHubsForm = (): ReactElement => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: hubs } = useQuery({
|
||||||
|
queryKey: ["hubs"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
host: settings?.data.directConnect?.client?.host,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||||
|
});
|
||||||
|
|
||||||
|
let hubList: any[] = [];
|
||||||
|
if (!isNil(hubs)) {
|
||||||
|
hubList = hubs?.data.map(({ hub_url, identity }) => ({
|
||||||
|
value: hub_url,
|
||||||
|
label: identity.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = 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: (data) => {
|
||||||
|
queryClient.setQueryData(["settings"], (oldData: any) =>
|
||||||
|
produce(oldData, (draft: any) => {
|
||||||
|
draft.data.directConnect.client = {
|
||||||
|
...draft.data.directConnect.client,
|
||||||
|
...data.data.directConnect.client,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = async (values) => {
|
||||||
|
const errors = {};
|
||||||
|
// Add any validation logic here if needed
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectAdapter = ({ input, ...rest }) => {
|
||||||
|
return <Select {...input} {...rest} isClearable isMulti />;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>Error loading settings.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isEmpty(hubList) && !isUndefined(hubs) ? (
|
||||||
|
<Form
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutation.mutate(values);
|
||||||
|
}}
|
||||||
|
validate={validate}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-10">
|
||||||
|
<h2 className="text-xl">Configure DC++ Hubs</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"
|
||||||
|
>
|
||||||
|
<h6 className="subtitle has-text-grey-light">
|
||||||
|
Select the hubs you want to perform searches against. Your
|
||||||
|
selection in the dropdown <strong>will replace</strong> the
|
||||||
|
existing selection.
|
||||||
|
</h6>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="block py-1 mt-3">AirDC++ Host</label>
|
||||||
|
<Field
|
||||||
|
name="hubs"
|
||||||
|
component={SelectAdapter}
|
||||||
|
className="basic-multi-select"
|
||||||
|
placeholder="Select Hubs to Search Against"
|
||||||
|
options={hubList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 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 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"></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="flex items-center mt-10 mb-4">
|
||||||
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
|
Default Hub for Searches
|
||||||
|
</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">
|
||||||
|
{settings?.data.directConnect?.client.hubs.map(
|
||||||
|
({ value, label }) => (
|
||||||
|
<div key={value}>
|
||||||
|
<div>{label}</div>
|
||||||
|
<span className="is-size-7">{value}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</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,89 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||||
|
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
AIRDCPP_SERVICE_BASE_URI,
|
||||||
|
SETTINGS_SERVICE_BASE_URI,
|
||||||
|
} from "../../../constants/endpoints";
|
||||||
|
|
||||||
|
export const AirDCPPSettingsForm = () => {
|
||||||
|
const [airDCPPSessionInformation, setAirDCPPSessionInformation] =
|
||||||
|
useState(null);
|
||||||
|
// Fetching all settings
|
||||||
|
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({
|
||||||
|
queryKey: ["airDCPPSettings"],
|
||||||
|
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch session information
|
||||||
|
const fetchSessionInfo = (host) => {
|
||||||
|
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use effect to trigger side effects on settings fetch success
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) {
|
||||||
|
const host = settingsData.data.directConnect.client.host;
|
||||||
|
fetchSessionInfo(host).then((response) => {
|
||||||
|
setAirDCPPSessionInformation(response.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settingsSuccess, settingsData]);
|
||||||
|
|
||||||
|
// Handle setting update and subsequent AirDC++ initialization
|
||||||
|
const { mutate } = useMutation({
|
||||||
|
mutationFn: (values) => {
|
||||||
|
console.log(values);
|
||||||
|
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
|
settingsPayload: values,
|
||||||
|
settingsKey: "directConnect",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
const host = response?.data?.directConnect?.client?.host;
|
||||||
|
if (host) {
|
||||||
|
const response = await fetchSessionInfo(host);
|
||||||
|
setAirDCPPSessionInformation(response.data);
|
||||||
|
// setState({ airDCPPClientConfiguration: host });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSettingsMutation = useMutation(() =>
|
||||||
|
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
|
settingsPayload: {},
|
||||||
|
settingsKey: "directConnect",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConnectionForm
|
||||||
|
initialData={initFormData}
|
||||||
|
submitHandler={mutate}
|
||||||
|
formHeading={"Configure AirDC++"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{airDCPPSessionInformation && (
|
||||||
|
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsData?.data && (
|
||||||
|
<p className="control mt-4">
|
||||||
|
<button
|
||||||
|
className="button is-danger"
|
||||||
|
onClick={() => deleteSettingsMutation.mutate()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AirDCPPSettingsForm;
|
||||||
38
src/client/components/Settings/DockerVars/DockerVars.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const DockerVars = (): React.ReactElement => {
|
||||||
|
const [environmentVariables, setEnvironmentVariables] = React.useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["docker-vars"],
|
||||||
|
queryFn: async () => {
|
||||||
|
await axios({
|
||||||
|
method: "GET",
|
||||||
|
url: "http://localhost:3000/api/settings/getEnvironmentVariables",
|
||||||
|
}).then((response) => {
|
||||||
|
setEnvironmentVariables(response.data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
});
|
||||||
|
console.log("Docker Vars: ", environmentVariables);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
<pre>
|
||||||
|
{Object.entries(environmentVariables).length > 0
|
||||||
|
? JSON.stringify(environmentVariables, null, 2)
|
||||||
|
: "No environment variables found."}
|
||||||
|
</pre>
|
||||||
|
</p>
|
||||||
|
{/* Add your form or content for Docker environment variables here */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerVars;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Form, Field } from "react-final-form";
|
||||||
|
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const ProwlarrSettingsForm = (props) => {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryFn: async (): any => {
|
||||||
|
return await axios({
|
||||||
|
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
host: "localhost",
|
||||||
|
port: "9696",
|
||||||
|
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: ["prowlarrConnectionResult"],
|
||||||
|
});
|
||||||
|
console.log(data);
|
||||||
|
const submitHandler = () => {};
|
||||||
|
const initialData = {};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Prowlarr Settings.
|
||||||
|
<Form
|
||||||
|
onSubmit={submitHandler}
|
||||||
|
initialValues={initialData}
|
||||||
|
render={({ handleSubmit }) => (
|
||||||
|
<form>
|
||||||
|
<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 Prowlarr integration here.</p>
|
||||||
|
<p>
|
||||||
|
Note that you need a Prowlarr instance hosted and running to
|
||||||
|
configure the integration.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
See{" "}
|
||||||
|
<a
|
||||||
|
className="underline"
|
||||||
|
href="http://airdcpp.net/docs/installation/installation.html"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>{" "}
|
||||||
|
for Prowlarr installation instructions for various platforms.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProwlarrSettingsForm;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const hostDetails = data?.data?.bittorrent?.client?.host;
|
||||||
|
// connect to qbittorrent client
|
||||||
|
|
||||||
|
// get qbittorrent client info
|
||||||
|
const { data: qbittorrentClientInfo } = useQuery({
|
||||||
|
queryKey: ["qbittorrentClientInfo"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// 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;
|
||||||
146
src/client/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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 ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
|
||||||
|
import DockerVars from "./DockerVars/DockerVars";
|
||||||
|
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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const toggleExpanded = (id: string) => {
|
||||||
|
setExpanded((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsContent = [
|
||||||
|
{ id: "adc-hubs", content: <AirDCPPHubsForm /> },
|
||||||
|
{ id: "adc-connection", content: <AirDCPPSettingsForm /> },
|
||||||
|
{id: "gen-docker-vars", content: <DockerVars />},
|
||||||
|
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> },
|
||||||
|
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> },
|
||||||
|
{ id: "core-service", content: <>a</> },
|
||||||
|
{ id: "flushdb", content: <SystemSettingsForm /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-white">
|
||||||
|
Import comics into the ThreeTwo library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Layout */}
|
||||||
|
<div className="flex gap-8 px-12 py-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="relative z-30">
|
||||||
|
<aside
|
||||||
|
className="sticky top-6 w-72 max-h-[90vh]
|
||||||
|
rounded-2xl shadow-xl backdrop-blur-md
|
||||||
|
bg-white/70 dark:bg-slate-800/60
|
||||||
|
border border-slate-200 dark:border-slate-700
|
||||||
|
overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-6 overflow-y-auto">
|
||||||
|
{map(settingsObject, (settingObject, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="mb-6 text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
|
||||||
|
{settingObject.category.toUpperCase()}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!isUndefined(settingObject.children) && (
|
||||||
|
<ul>
|
||||||
|
{map(settingObject.children, (item, idx) => {
|
||||||
|
const isOpen = expanded[item.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={idx} className="mb-1">
|
||||||
|
<div
|
||||||
|
onClick={() => toggleExpanded(item.id)}
|
||||||
|
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||||
|
item.id === active
|
||||||
|
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
onClick={() => setActive(item.id.toString())}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{item.displayName}
|
||||||
|
</span>
|
||||||
|
{!isUndefined(item.children) && (
|
||||||
|
<span className="text-xs opacity-60">
|
||||||
|
{isOpen ? "−" : "+"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isUndefined(item.children) && isOpen && (
|
||||||
|
<ul className="pl-4 mt-1">
|
||||||
|
{map(item.children, (subItem) => (
|
||||||
|
<li key={subItem.id} className="mb-1">
|
||||||
|
<a
|
||||||
|
onClick={() =>
|
||||||
|
setActive(subItem.id.toString())
|
||||||
|
}
|
||||||
|
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||||
|
subItem.id.toString() === active
|
||||||
|
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subItem.displayName}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 px-2 py-2">
|
||||||
|
{settingsContent.map(({ id, content }) =>
|
||||||
|
active === id ? <div key={id}>{content}</div> : null,
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => flushDb()}
|
||||||
|
>
|
||||||
|
<span className="pt-1 px-1">
|
||||||
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
|
||||||
|
</span>
|
||||||
|
<span>Flush DB & Temporary Folders</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemSettingsForm;
|
||||||