mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
480 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40f74ea375 | |||
| adf260bc35 | |||
| 432d6f0cd5 | |||
| 3de314ae9a | |||
| 952aa0c6ca | |||
| bd81c2e005 | |||
| b471ed2fa0 | |||
| 7507ab64ad | |||
| e4587d36bc | |||
| 7d6d3640ad | |||
| 3071d44e32 | |||
| 7a09c9006a | |||
| 959560c7a7 | |||
| ff679b30d8 | |||
| f7a360c2d8 | |||
| 1065b430e3 | |||
| 5abf7032a5 | |||
| 44336c546a | |||
| a4c6e6611c | |||
| 0b457a2797 | |||
| 653751bede | |||
| a02bf4a81e | |||
| 5271d12f4c | |||
| c2e2f0b9b3 | |||
| 72d319902e | |||
| bbd0fd68cb | |||
| 0fb1e1598d | |||
| 4645582f5d | |||
| ac9c51dd33 | |||
| f51d27860a | |||
| 4a7439a1ea | |||
| 00e19399d7 | |||
| cb723acef7 | |||
| 794bed12bd | |||
| bae8220e75 | |||
| 0cc5e1626b | |||
| da0ca665a6 | |||
| a91cf21aa9 | |||
| 39b2636711 | |||
| 2618d8412b | |||
| 445ebdf357 | |||
| 60134dc364 | |||
| aa70752244 | |||
| 0f39535097 | |||
| e086bec9da | |||
| dcdcf29114 | |||
| c5c73ddff3 | |||
| f18ee4284f | |||
| 0fbc11386e | |||
| a68282b4bf | |||
| e64908ad06 | |||
| af0913df64 | |||
| 5685dd1cc5 | |||
| af2fd2a66a | |||
| db2a51a26b | |||
| cf930418cb | |||
| 911848ad11 | |||
| 93f745aecb | |||
| 981a1f0226 | |||
| 8188456788 | |||
| 1eace2c64c | |||
| c6ee5409f8 | |||
| b05ed57762 | |||
| 0f1d1099f6 | |||
| 40a24f4247 | |||
| a6862e86d4 | |||
| bfc1b697bd | |||
| 276f62cb76 | |||
| 45a81ad5f6 | |||
| ce88acb9e5 | |||
| bd34b803f1 | |||
| 2559f65f35 | |||
| 93c21ea659 | |||
| 85ad38c321 | |||
| b6a204f5bd | |||
| f7b8e2d852 | |||
| 946017c8bd | |||
| ec5256dabd | |||
| 4e707076a1 | |||
| 66a3cc268b | |||
| 96949905b9 | |||
| 30c0199039 | |||
| 7a7cb78f82 | |||
| 8931ba8c43 | |||
| d50981c151 | |||
| df4deb1415 | |||
| aa5e999ed4 | |||
| 84d4b0c529 | |||
| d3e5691478 | |||
| 1000b02ae0 | |||
| 1f795889a9 | |||
| d33b45233a | |||
| 4f6df5b9a3 | |||
| 341b586cb3 | |||
| 9dcc9665ce | |||
| 1cd90926df | |||
| ac1ff61e6d | |||
| 6ea41f79e9 | |||
| dad02a2a30 | |||
| 280490fb36 | |||
| 455315a362 | |||
| df51406638 | |||
| 531d42ef18 | |||
| 2645e8cd05 | |||
| b2dc44a919 | |||
| c8db397a3b | |||
| 6384d4b77a | |||
| 1039732d87 | |||
| 011123f690 | |||
| e602a35b0c | |||
| 7792d3426e | |||
| b59c8f85ad | |||
| 18834ac28e | |||
| bf68e32ac8 | |||
| 54eb041fe4 | |||
| 57d8c100f9 | |||
| 56d973b99d | |||
| 670e5cdf6a | |||
| 1b35392f9c | |||
| c4e1ffe023 | |||
| 44f4959477 | |||
| 0582b57d60 | |||
| 83d96fd2a1 | |||
| 8ac89c420c | |||
| 968c2f4ad5 | |||
| ad940f30d5 | |||
| 308ad4e063 | |||
| 4d709b7eb5 | |||
| 5760ad924e | |||
| fff171c8c9 | |||
| 44ff566a1d | |||
| 853f422964 | |||
| 3bb0917374 | |||
| a86f0d0f34 | |||
| 16a9d7fc2e | |||
| ee2b4abc85 | |||
| a6c2799521 | |||
| 2370e4d2c6 | |||
| 32b0384ea0 | |||
| 50d4ffdb7b | |||
| 96463641f9 | |||
| ddbba5d596 | |||
| 2a04f4531e | |||
| a5b6fb781f | |||
| 8dfdab9d73 | |||
| 3a95270dfb | |||
| 2960ca54df | |||
| f5fe3c6b1c | |||
| a612cc15fb | |||
| c9c0818069 | |||
| 2f8efc382f | |||
| a0fb1880bd | |||
| a408f14425 | |||
| 243b6c8927 | |||
| ff3a44d017 | |||
| 67ef1f7112 | |||
| 5d7b8a1ef9 | |||
| a68f3eea95 | |||
| 220fc42bf2 | |||
| a45e6ea3da | |||
| 88394d4636 | |||
| ef1ab940f5 | |||
| 97a1c408d8 | |||
| abbf77df13 | |||
| 3b4021f680 | |||
| 68b1923cb6 | |||
| 3cdd4b29a5 | |||
| af84c0f6de | |||
| 85a65f84d0 | |||
| 5027a911cd | |||
| ac63bf7599 | |||
| 30b0e0b8fb | |||
| ddda058d8d | |||
| 46db25e8e0 | |||
| c07f421322 | |||
| 99a77966ad | |||
| d00b917575 | |||
| 4fd8334c37 | |||
| 3aa4630558 | |||
| cde5af7066 | |||
| eb528e1726 | |||
| 5e01cc38fe | |||
| 9a787ccbc3 | |||
| 8a83c0df4e | |||
| 87dea01917 | |||
| 586ee4f0ba | |||
| 53f3387e1a | |||
| be5d1918aa | |||
| df2cc0ffa9 | |||
| b8cfc3a201 | |||
| 8dc60ac2ea | |||
| 1719335d02 | |||
| 0cd46abc66 | |||
| e4fd7c58ee | |||
| d4abee52db | |||
| d29c94e898 | |||
| 1c19a91ee2 | |||
| 7eb5c253e9 | |||
| 22a660aabf | |||
| 6e9466c9d2 | |||
| ab34fb260c | |||
| 0e9a659828 | |||
| 361d37d742 | |||
| c6adb4ee18 | |||
| 8349fb68a4 | |||
| 0e1e4de528 | |||
| b47788a85a | |||
| f7004549b8 | |||
| 8d99400c5f | |||
| ce59acae7a | |||
| 37c5911a23 | |||
| 8694b4beaf | |||
| 3b315ad880 | |||
| 33107670ce | |||
| f116e2f1d0 | |||
| ebf6221876 | |||
| 2a910335af | |||
| 9ea26474b4 | |||
| df8a6ee6da | |||
| 70ea1711ce | |||
| 2773c1e67f | |||
| dcfd1c8765 | |||
| 10b6047df8 | |||
| 8de735a2ca | |||
| 6c2350c9c7 | |||
| a994c43857 | |||
| 7e4532fb14 | |||
| d184d6fba5 | |||
| 92f5a90629 | |||
| 2a36804e8d | |||
| 87b6e79952 | |||
| b75a838e14 | |||
| ae7c72ab85 | |||
| 5cee68d76c | |||
| f444496915 | |||
| a812e3ed46 | |||
| 1be089b53e | |||
| a7f4e161de | |||
| ba31eb0071 | |||
| 192474c950 | |||
| 87b72fbd30 | |||
| 6acfa02314 | |||
| bdba7bdd13 | |||
| 1b244c68b8 | |||
| 281f626e8c | |||
| 5be4f51d7e | |||
| cd7782ba1e | |||
| 6d97bc083c | |||
| ff4b1be9ae | |||
| ba16c3db2f | |||
| 69b06a8352 | |||
| 687788767f | |||
| 94a1e63963 | |||
| 360913ee78 | |||
| ea366f263a | |||
| 0d11cb59e9 | |||
| 2208f90d8e | |||
| 07100121ef | |||
| a0e550569e | |||
| bbbe2e0588 | |||
| 9d31b24e8c | |||
| 38ba324fa9 | |||
| c00016fa19 | |||
| 4d5a305d1b | |||
| f9ca52ee2f | |||
| f6c393545c | |||
| 466aee62fe | |||
| eab0800376 | |||
| 1725f42698 | |||
| f5cdf8b7b6 | |||
| fe082e7537 | |||
| c87b96dd0b | |||
| 9d76ca8c24 | |||
| 5f21653e07 | |||
| 0035cd9177 | |||
| 899b221842 | |||
| a317086f81 | |||
| b83313b231 | |||
| 62af879bfa | |||
| 96f98f6c78 | |||
| 841d5051cb | |||
| 0768e2177b | |||
| 0e4d67cf29 | |||
| 00fcc881ee | |||
| ca8d9efcfd | |||
| 0e7be6392d | |||
| 4af5258602 | |||
| 23c6256552 | |||
| ef0e3fd346 | |||
| b70fad13a7 | |||
| d2f9735250 | |||
| 06d6311080 | |||
| 674da55bde | |||
| dc084aff7c | |||
| 4c2cf64f53 | |||
| f4c4bb536c | |||
| 47edb3008b | |||
| e28dadc94e | |||
| 3dc9bd2264 | |||
| 9302601307 | |||
| 650ba98039 | |||
| bb2173788b | |||
| c8be2849b9 | |||
| aa269f26ee | |||
| 5c26b0d6dc | |||
| c9d3c35bdd | |||
| 9255de710f | |||
| 39b251774f | |||
| 156e511d4a | |||
| 5cd6f3eacb | |||
| a0e5a03052 | |||
| e53641add1 | |||
| 45cdfd5306 | |||
| 3d352ed062 | |||
| bac7be5163 | |||
| 717d44e029 | |||
| 8da4475a74 | |||
| 680504779f | |||
| 926d0e66a5 | |||
| 0f3dd51d6b | |||
| 53c3798691 | |||
| 6d4e8ea544 | |||
| 0bd94a2290 | |||
| cff599f688 | |||
| fa85d9834f | |||
| aaf0a3c6af | |||
| 5ed2a8affa | |||
| de690fbf29 | |||
| 12c3c3f356 | |||
| 1ddcabcc12 | |||
| 8b04f2c96b | |||
| 66e2fc138a | |||
| 6817113523 | |||
| 6ad4385b18 | |||
| 012fd71ab4 | |||
| 373ff6520a | |||
| 8a0e9250c8 | |||
| 871a5fe755 | |||
| 1493c3de90 | |||
| 808074e478 | |||
| 49193b9b00 | |||
| 1cb470fb2d | |||
| e443176a79 | |||
| bec257c99f | |||
| f2df493d79 | |||
| b74f61c025 | |||
| c76c287e66 | |||
| 8e7eaa680a | |||
| 30cdb3ec8f | |||
| 9c367e7d35 | |||
| 4f5e05c008 | |||
| d2f95e5970 | |||
| 82bcd03f15 | |||
| fe799f30c8 | |||
| 54123917af | |||
| 3b737c0bee | |||
| 14bf4da06c | |||
| a72dfcecd3 | |||
| 160a249dc6 | |||
| f9a2534f80 | |||
| 06fe2ccf16 | |||
| 13c0878357 | |||
| 3ef6a7bfc4 | |||
| e214e00dfb | |||
| 9b5aea223d | |||
| 60100c51fe | |||
| 27c111d273 | |||
| 1b9d83f367 | |||
| 96b8186add | |||
| 27dab3c989 | |||
| bcb95d1462 | |||
| 4371c7877d | |||
| d72d635c68 | |||
| b724b4d508 | |||
| 8bbbe650f1 | |||
| 651bd17612 | |||
| dd01e632a2 | |||
| 43ee8f3b85 | |||
| 4841f90cc1 | |||
| bedcac4e35 | |||
| 5260a82e88 | |||
| 1efb300988 | |||
| 6b43ee7fe5 | |||
| e99d7b8b29 | |||
| d2ad7fef77 | |||
| ddb6a860ae | |||
| 3039031924 | |||
| 8665616c2e | |||
| 4453b0ee9f | |||
| 487154e68c | |||
| 60609263ab | |||
| 4a245d2504 | |||
| 48c3a82078 | |||
| 4a59459773 | |||
| eefa8c3982 | |||
| 8fe2f3b4cc | |||
| 6a9105605d | |||
| 60d4cee0a9 | |||
| 8658cb8306 | |||
| c74a01f546 | |||
| 2aeb38a271 | |||
| a2c7638141 | |||
| c35e840694 | |||
| ff6e64f12a | |||
| 16fa27e4f6 | |||
| 16734c2c59 | |||
| 392b3d8339 | |||
| d4e523c337 | |||
| d49c0092c2 | |||
| d75009f088 | |||
| d416dc6618 | |||
| 7233e6e5c3 | |||
| bd8ae9497f | |||
| 34b11dc2c7 | |||
| 30dea57346 | |||
| 7448592216 | |||
| 049bd3ab2c | |||
| c3608c101b | |||
| 1bec9f0108 | |||
| 09b297cd8e | |||
| b7cd55e692 | |||
| 986939ecb6 | |||
| a5e97af3a3 | |||
| 4cee5faecd | |||
| 711add74ef | |||
| f6f09c54bc | |||
| 0f58ebb87b | |||
| 46347a8fe4 | |||
| a354d811d9 | |||
| 22d757362a | |||
| 8afcea7e87 | |||
| fb05e913a0 | |||
| 490888ad71 | |||
| 20d71bfa81 | |||
| ec6a7bd3d9 | |||
| b449d906ec | |||
| f66bec5545 | |||
| ce5f444012 | |||
| 8506044232 | |||
| 079dd8e280 | |||
| 8262a163db | |||
| d6b22ef736 | |||
| 39f4897fc5 | |||
| fc6a33e5fd | |||
| 7d97d21d40 | |||
| fcf9d39047 | |||
| d33cae7618 | |||
| 8b184ed48d | |||
| d3309a810b | |||
| 3866c81588 | |||
| 2c31f594a4 | |||
| c572c56a39 | |||
| e670a083a3 | |||
| 9b23e1759d | |||
| 14e3470b12 | |||
| 8ce51a6163 | |||
| 1d4237d687 | |||
| b7c0515af7 | |||
| 75edfcdb5b | |||
| 51d19328be | |||
| d405498af4 | |||
| 696f79aea1 | |||
| d2da8d0b9a | |||
| 4e961192d4 | |||
| 8b90524a2c | |||
| c9b8770b9f | |||
| e568ec8878 | |||
| ac3df03d88 | |||
| 7c9728683c | |||
| d921d04abf | |||
| 5400c8c8ef | |||
| 58e96cd4fe | |||
| aa09f3a86f | |||
| a5daded453 | |||
| 4968cb8e18 | |||
| 27c6e02da8 | |||
| 68d1b55aea | |||
| 32dc3e84b9 | |||
| 460fcdf2f5 | |||
| c6369f9f26 |
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"projectName": "Mango",
|
||||||
|
"projectOwner": "hkalexling",
|
||||||
|
"repoType": "github",
|
||||||
|
"repoHost": "https://github.com",
|
||||||
|
"files": [
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"imageSize": 100,
|
||||||
|
"commit": false,
|
||||||
|
"commitConvention": "none",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"login": "hkalexling",
|
||||||
|
"name": "Alex Ling",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
|
||||||
|
"profile": "https://github.com/hkalexling/",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc",
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jaredlt",
|
||||||
|
"name": "jaredlt",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
|
||||||
|
"profile": "https://github.com/jaredlt",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"ideas",
|
||||||
|
"design"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "shincurry",
|
||||||
|
"name": "ココロ",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
|
||||||
|
"profile": "https://windisco.com/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "noirscape",
|
||||||
|
"name": "Valentijn",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
|
||||||
|
"profile": "https://catgirlsin.space/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "flying-sausages",
|
||||||
|
"name": "flying-sausages",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
|
||||||
|
"profile": "https://github.com/flying-sausages",
|
||||||
|
"contributions": [
|
||||||
|
"doc",
|
||||||
|
"ideas"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "XavierSchiller",
|
||||||
|
"name": "Xavier",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
|
||||||
|
"profile": "https://github.com/XavierSchiller",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "WROIATE",
|
||||||
|
"name": "Jarao",
|
||||||
|
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
|
||||||
|
"profile": "https://github.com/WROIATE",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Leeingnyo",
|
||||||
|
"name": "이인용",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
|
||||||
|
"profile": "https://github.com/Leeingnyo",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "h45h74x",
|
||||||
|
"name": "Simon",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
|
||||||
|
"profile": "http://h45h74x.eu.org",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contributorsPerLine": 7,
|
||||||
|
"skipCi": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Lint/UselessAssign:
|
||||||
|
Excluded:
|
||||||
|
- src/routes/*
|
||||||
|
- src/server.cr
|
||||||
|
Lint/UnusedArgument:
|
||||||
|
Excluded:
|
||||||
|
- src/routes/*
|
||||||
|
Metrics/CyclomaticComplexity:
|
||||||
|
Enabled: false
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
lib
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
open_collective: mango
|
||||||
patreon: hkalexling
|
patreon: hkalexling
|
||||||
|
ko_fi: hkalexling
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ A clear and concise description of what you expected to happen.
|
|||||||
- Mango Version [e.g. v0.1.0]
|
- Mango Version [e.g. v0.1.0]
|
||||||
|
|
||||||
**Docker (if you are running Mango in a Docker container)**
|
**Docker (if you are running Mango in a Docker container)**
|
||||||
- The `docker-compose.yml` file you are using
|
- The `docker-compose.yml` file you are using, or your `.env` file.
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here. Add screenshots if applicable.
|
Add any other context about the problem here. Add screenshots if applicable.
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ assignees: ''
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. E.g. I'm always frustrated when [...]
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe a small use-case for this feature request**
|
||||||
|
How would you imagine this to be used? What would be the advantage of this for the users of the application?
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
@@ -2,23 +2,39 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev, hotfix/* ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:0.32.1-alpine
|
image: crystallang/crystal:0.35.1-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk add --no-cache yarn yaml sqlite-static
|
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make
|
run: make static || make static
|
||||||
|
- name: Linter
|
||||||
|
run: make check
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
run: make test
|
||||||
|
- name: Upload binary
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: mango
|
||||||
|
path: mango
|
||||||
|
- name: build arm32v7 object file
|
||||||
|
run: make arm32v7 || make arm32v7
|
||||||
|
- name: build arm64v8 object file
|
||||||
|
run: make arm64v8 || make arm64v8
|
||||||
|
- name: Upload object files
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: object files
|
||||||
|
path: ./*.o
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: Publish Dockerhub
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- name: Get release version
|
||||||
|
id: get_version
|
||||||
|
run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
|
||||||
|
- name: Publish to Dockerhub
|
||||||
|
uses: elgohr/Publish-Docker-Github-Action@master
|
||||||
|
with:
|
||||||
|
name: hkalexling/mango
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
tags: "latest,${{ env.RELEASE_VERSION }}"
|
||||||
+6
-1
@@ -7,4 +7,9 @@ node_modules
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
dist
|
dist
|
||||||
mango
|
mango
|
||||||
docker-compose.yml
|
.env
|
||||||
|
*.md
|
||||||
|
public/css/uikit.css
|
||||||
|
public/img/*.svg
|
||||||
|
public/js/*.min.js
|
||||||
|
public/css/*.css
|
||||||
|
|||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
FROM crystallang/crystal:0.32.1-alpine AS builder
|
FROM crystallang/crystal:0.35.1-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY package*.json .
|
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
RUN apk add --no-cache yarn yaml sqlite-static \
|
RUN make static || make static
|
||||||
&& make static
|
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM arm32v7/ubuntu:18.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||||
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|
||||||
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
|
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["./mango"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM arm64v8/ubuntu:18.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||||
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|
||||||
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
|
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["./mango"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
PREFIX=/usr/local
|
PREFIX ?= /usr/local
|
||||||
INSTALL_DIR=$(PREFIX)/bin
|
INSTALL_DIR=$(PREFIX)/bin
|
||||||
|
|
||||||
all: uglify | build
|
all: uglify | build
|
||||||
@@ -7,14 +7,18 @@ uglify:
|
|||||||
yarn
|
yarn
|
||||||
yarn uglify
|
yarn uglify
|
||||||
|
|
||||||
|
setup: libs
|
||||||
|
yarn
|
||||||
|
yarn gulp dev
|
||||||
|
|
||||||
build: libs
|
build: libs
|
||||||
crystal build src/mango.cr --release --progress
|
crystal build src/mango.cr --release --progress --error-trace
|
||||||
|
|
||||||
static: uglify | libs
|
static: uglify | libs
|
||||||
crystal build src/mango.cr --release --progress --static
|
crystal build src/mango.cr --release --progress --static --error-trace
|
||||||
|
|
||||||
libs:
|
libs:
|
||||||
shards install
|
shards install --production
|
||||||
|
|
||||||
run:
|
run:
|
||||||
crystal run src/mango.cr --error-trace
|
crystal run src/mango.cr --error-trace
|
||||||
@@ -22,6 +26,17 @@ run:
|
|||||||
test:
|
test:
|
||||||
crystal spec
|
crystal spec
|
||||||
|
|
||||||
|
check:
|
||||||
|
crystal tool format --check
|
||||||
|
./bin/ameba
|
||||||
|
./dev/linewidth.sh
|
||||||
|
|
||||||
|
arm32v7:
|
||||||
|
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
||||||
|
|
||||||
|
arm64v8:
|
||||||
|
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cp mango $(INSTALL_DIR)/mango
|
cp mango $(INSTALL_DIR)/mango
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
@@ -10,32 +7,41 @@
|
|||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
- Multi-user support
|
- Multi-user support
|
||||||
|
- OPDS support
|
||||||
- Dark/light mode switch
|
- Dark/light mode switch
|
||||||
- Supports both `.zip` and `.cbz` formats
|
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
|
- Thumbnail generation
|
||||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||||
|
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
|
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Pre-built Binary
|
### Pre-built Binary
|
||||||
|
|
||||||
1. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
|
Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
1. Make sure you have docker installed and running. You will also need `docker-compose`
|
1. Make sure you have docker installed and running. You will also need `docker-compose`
|
||||||
2. Clone the repository
|
2. Clone the repository
|
||||||
3. Copy `docker-compose.example.yml` to `docker-compose.yml`
|
3. Copy the `env.example` file to `.env`
|
||||||
4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine
|
4. Fill out the values in the `.env` file. Note that the main and config directories will be created if they don't already exist. The files in these folders will be owned by the root user
|
||||||
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
|
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
|
||||||
6. Head over to `localhost:9000` to log in
|
6. Head over to `localhost:9000` (or a different port if you changed it) to log in
|
||||||
|
|
||||||
|
### Docker (via Dockerhub)
|
||||||
|
|
||||||
|
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
1. Make sure you have Crystal, Node and Yarn installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
|
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
|
||||||
2. Clone the repository
|
2. Clone the repository
|
||||||
3. `make && sudo make install`
|
3. `make && sudo make install`
|
||||||
4. Start Mango by running the command `mango`
|
4. Start Mango by running the command `mango`
|
||||||
@@ -46,11 +52,21 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango e-manga server/reader. Version 0.2.0
|
Mango - Manga Server and Web Reader. Version 0.19.1
|
||||||
|
|
||||||
-v, --version Show version
|
Usage:
|
||||||
-h, --help Show help
|
|
||||||
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
|
mango [sub_command] [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
-c PATH, --config=PATH Path to the config file [type:String]
|
||||||
|
-h, --help Show this help.
|
||||||
|
-v, --version Show version.
|
||||||
|
|
||||||
|
Sub Commands:
|
||||||
|
|
||||||
|
admin Run admin tools
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
@@ -60,24 +76,37 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
|||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
port: 9000
|
port: 9000
|
||||||
|
base_url: /
|
||||||
|
session_secret: mango-session-secret
|
||||||
library_path: ~/mango/library
|
library_path: ~/mango/library
|
||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
|
thumbnail_generation_interval_hours: 24
|
||||||
|
db_optimization_interval_hours: 24
|
||||||
log_level: info
|
log_level: info
|
||||||
|
upload_path: ~/mango/uploads
|
||||||
|
plugin_path: ~/mango/plugins
|
||||||
|
download_timeout_seconds: 30
|
||||||
|
page_margin: 30
|
||||||
|
disable_login: false
|
||||||
|
default_username: ""
|
||||||
mangadex:
|
mangadex:
|
||||||
base_url: https://mangadex.org
|
base_url: https://mangadex.org
|
||||||
api_url: https://mangadex.org/api
|
api_url: https://mangadex.org/api
|
||||||
download_wait_seconds: 5
|
download_wait_seconds: 5
|
||||||
download_retries: 4
|
download_retries: 4
|
||||||
download_queue_db_path: ~/mango/queue.db
|
download_queue_db_path: ~/mango/queue.db
|
||||||
|
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||||
|
manga_rename_rule: '{title}'
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
|
You can organize your archive files in nested folders in the library directory. Here's an example:
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
@@ -120,6 +149,34 @@ Mobile UI:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
|
||||||
|
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
|
||||||
|
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
|
||||||
|
&& echo "The above lines exceed the 80 characters limit" \
|
||||||
|
|| exit 0
|
||||||
@@ -7,9 +7,9 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
expose:
|
expose:
|
||||||
- 9000
|
- ${PORT}
|
||||||
ports:
|
ports:
|
||||||
- 9000:9000
|
- "${PORT}:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/mango:/root/mango
|
- ${MAIN_DIRECTORY_PATH}:/root/mango
|
||||||
- ~/.config/mango:/root/.config/mango
|
- ${CONFIG_DIRECTORY_PATH}:/root/.config/mango
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Port that exposes the HTTP frontend
|
||||||
|
PORT=9000
|
||||||
|
|
||||||
|
# Path to the mango main directory
|
||||||
|
# This directory holds the database and the library files
|
||||||
|
MAIN_DIRECTORY_PATH=
|
||||||
|
|
||||||
|
# Path to the mango config directory
|
||||||
|
# This directory holds the mango configuration path
|
||||||
|
CONFIG_DIRECTORY_PATH=
|
||||||
+53
-12
@@ -1,29 +1,70 @@
|
|||||||
const gulp = require('gulp');
|
const gulp = require('gulp');
|
||||||
const minify = require("gulp-babel-minify");
|
const babel = require('gulp-babel');
|
||||||
|
const minify = require('gulp-babel-minify');
|
||||||
const minifyCss = require('gulp-minify-css');
|
const minifyCss = require('gulp-minify-css');
|
||||||
|
const less = require('gulp-less');
|
||||||
|
|
||||||
gulp.task('minify-js', () => {
|
// Copy libraries from node_moduels to public/js
|
||||||
return gulp.src('public/js/*.js')
|
gulp.task('copy-js', () => {
|
||||||
|
return gulp.src([
|
||||||
|
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
||||||
|
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
||||||
|
'node_modules/uikit/dist/js/uikit.min.js',
|
||||||
|
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
||||||
|
])
|
||||||
|
.pipe(gulp.dest('public/js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy UIKit SVG icons to public/img
|
||||||
|
gulp.task('copy-uikit-icons', () => {
|
||||||
|
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||||
|
.pipe(gulp.dest('public/img'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compile less
|
||||||
|
gulp.task('less', () => {
|
||||||
|
return gulp.src('public/css/*.less')
|
||||||
|
.pipe(less())
|
||||||
|
.pipe(gulp.dest('public/css'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transpile and minify JS files and output to dist
|
||||||
|
gulp.task('babel', () => {
|
||||||
|
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
|
||||||
|
.pipe(babel({
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: '>0.25%, not dead, ios>=9'
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
}))
|
||||||
.pipe(minify({
|
.pipe(minify({
|
||||||
removeConsole: true
|
removeConsole: true,
|
||||||
|
builtIns: false
|
||||||
}))
|
}))
|
||||||
.pipe(gulp.dest('dist/js'));
|
.pipe(gulp.dest('dist/js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Minify CSS and output to dist
|
||||||
gulp.task('minify-css', () => {
|
gulp.task('minify-css', () => {
|
||||||
return gulp.src('public/css/*.css')
|
return gulp.src('public/css/*.css')
|
||||||
.pipe(minifyCss())
|
.pipe(minifyCss())
|
||||||
.pipe(gulp.dest('dist/css'));
|
.pipe(gulp.dest('dist/css'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('img', () => {
|
// Copy static files (includeing images) to dist
|
||||||
return gulp.src('public/img/*')
|
gulp.task('copy-files', () => {
|
||||||
.pipe(gulp.dest('dist/img'));
|
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
|
||||||
});
|
base: 'public'
|
||||||
|
})
|
||||||
gulp.task('favicon', () => {
|
|
||||||
return gulp.src('public/favicon.ico')
|
|
||||||
.pipe(gulp.dest('dist'));
|
.pipe(gulp.dest('dist'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon'));
|
// Set up the public folder for development
|
||||||
|
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
||||||
|
|
||||||
|
// Set up the dist folder for deployment
|
||||||
|
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||||
|
|
||||||
|
// Default task
|
||||||
|
gulp.task('default', gulp.series('dev', 'deploy'));
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
class ForeignKeys < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add foreign key to tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
|
||||||
|
-- add foreign key to thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove foreign key from thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove foreign key from tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateIds < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
is_title INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE ids;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateTags < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE tags;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateThumbnails < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE thumbnails;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class CreateTitles < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- create titles
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- migrate data from ids to titles
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT id, path, null
|
||||||
|
FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
DELETE FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
-- remove the is_title column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- insert the is_title column
|
||||||
|
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- migrate data from titles to ids
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, 1
|
||||||
|
FROM titles;
|
||||||
|
|
||||||
|
-- remove titles
|
||||||
|
DROP TABLE titles;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateUsers < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
token TEXT,
|
||||||
|
admin INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE users;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
+23
-14
@@ -1,16 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "mango",
|
"name": "mango",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/hkalexling/Mango.git",
|
"repository": "https://github.com/hkalexling/Mango.git",
|
||||||
"author": "Alex Ling <hkalexling@gmail.com>",
|
"author": "Alex Ling <hkalexling@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gulp": "^4.0.2",
|
"@babel/preset-env": "^7.11.5",
|
||||||
"gulp-babel-minify": "^0.5.1",
|
"all-contributors-cli": "^6.19.0",
|
||||||
"gulp-minify-css": "^1.2.4"
|
"gulp": "^4.0.2",
|
||||||
},
|
"gulp-babel": "^8.0.0",
|
||||||
"scripts": {
|
"gulp-babel-minify": "^0.5.1",
|
||||||
"uglify": "gulp"
|
"gulp-less": "^4.0.1",
|
||||||
}
|
"gulp-minify-css": "^1.2.4",
|
||||||
|
"less": "^3.11.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"uglify": "gulp"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||||
|
"uikit": "^3.5.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
.uk-alert-close {
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
.uk-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.uk-card-media-top {
|
|
||||||
max-height: 350px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.acard:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.uk-list li {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.reader-bg {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
#scan-status {
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
.break-word {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
.uk-logo > img {
|
|
||||||
max-height: 90px;
|
|
||||||
}
|
|
||||||
.uk-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#selectable .ui-selecting {
|
|
||||||
background: #EEE6B9;
|
|
||||||
}
|
|
||||||
#selectable .ui-selected {
|
|
||||||
background: #F4E487;
|
|
||||||
}
|
|
||||||
#selectable .ui-selecting.dark {
|
|
||||||
background: #5E5731;
|
|
||||||
}
|
|
||||||
#selectable .ui-selected.dark {
|
|
||||||
background: #9D9252;
|
|
||||||
}
|
|
||||||
td > .uk-dropdown {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Item cards
|
||||||
|
.item .uk-card {
|
||||||
|
cursor: pointer;
|
||||||
|
.uk-card-media-top {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
&.grayscale {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uk-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
.uk-card-title {
|
||||||
|
max-height: 3em;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jQuery selectable
|
||||||
|
#selectable {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #EEE6B9;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #F4E487;
|
||||||
|
}
|
||||||
|
.uk-light & {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #5E5731;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #9D9252;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit modal
|
||||||
|
#edit-modal {
|
||||||
|
.uk-grid > div {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
#cover {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
#cover-upload {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.uk-modal-body .uk-inline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
.uk-light {
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-modal-header,
|
||||||
|
.uk-modal-body,
|
||||||
|
.uk-modal-footer {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-dropdown {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.uk-nav-header,
|
||||||
|
.uk-description-list > dt {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpine magic
|
||||||
|
[x-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch select bar on title page
|
||||||
|
#select-bar-controls {
|
||||||
|
a {
|
||||||
|
transform: scale(1.5, 1.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totop button
|
||||||
|
#totop-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100vh;
|
||||||
|
right: 2em;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
position: fixed;
|
||||||
|
position: sticky;
|
||||||
|
top: calc(100vh - 5em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
.uk-alert-close {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.break-word {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.uk-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@light-gray: #e5e5e5;
|
||||||
|
@gray: #666666;
|
||||||
|
@black: #141414;
|
||||||
|
@blue: rgb(30, 135, 240);
|
||||||
|
@white1: rgba(255, 255, 255, .1);
|
||||||
|
@white2: rgba(255, 255, 255, .2);
|
||||||
|
@white7: rgba(255, 255, 255, .7);
|
||||||
|
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @light-gray;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: @blue;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
.select2-results__option--highlighted.select2-results__option--selectable {
|
||||||
|
background-color: @blue;
|
||||||
|
}
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @light-gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light {
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection {
|
||||||
|
background-color: @white1;
|
||||||
|
}
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @white2;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: white;
|
||||||
|
color: @gray;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.select2-search__field {
|
||||||
|
color: @white7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
background-color: @black;
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @white2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@import "node_modules/uikit/src/less/uikit.theme.less";
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: @label-padding-vertical @label-padding-horizontal;
|
||||||
|
background: @label-background;
|
||||||
|
line-height: @label-line-height;
|
||||||
|
font-size: @label-font-size;
|
||||||
|
color: @label-color;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
.hook-label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-success {
|
||||||
|
background-color: @label-success-background;
|
||||||
|
color: @label-success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-warning {
|
||||||
|
background-color: @label-warning-background;
|
||||||
|
color: @label-warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-danger {
|
||||||
|
background-color: @label-danger-background;
|
||||||
|
color: @label-danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-pending {
|
||||||
|
background-color: @global-secondary-background;
|
||||||
|
color: @global-inverse-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@internal-divider-icon-image: "../img/divider-icon.svg";
|
||||||
|
@internal-form-select-image: "../img/form-select.svg";
|
||||||
|
@internal-form-datalist-image: "../img/form-datalist.svg";
|
||||||
|
@internal-form-radio-image: "../img/form-radio.svg";
|
||||||
|
@internal-form-checkbox-image: "../img/form-checkbox.svg";
|
||||||
|
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
|
||||||
|
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
|
||||||
|
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
|
||||||
|
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||||
|
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||||
|
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
+55
-25
@@ -1,25 +1,55 @@
|
|||||||
var scanning = false;
|
const component = () => {
|
||||||
function scan() {
|
return {
|
||||||
scanning = true;
|
progress: 1.0,
|
||||||
$('#scan-status > div').removeAttr('hidden');
|
generating: false,
|
||||||
$('#scan-status > span').attr('hidden', '');
|
scanning: false,
|
||||||
var color = $('#scan').css('color');
|
scanTitles: 0,
|
||||||
$('#scan').css('color', 'gray');
|
scanMs: -1,
|
||||||
$.post('/api/admin/scan', function (data) {
|
themeSetting: '',
|
||||||
var ms = data.milliseconds;
|
|
||||||
var titles = data.titles;
|
init() {
|
||||||
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
this.getProgress();
|
||||||
$('#scan-status > span').removeAttr('hidden');
|
setInterval(() => {
|
||||||
$('#scan').css('color', color);
|
this.getProgress();
|
||||||
$('#scan-status > div').attr('hidden', '');
|
}, 5000);
|
||||||
scanning = false;
|
|
||||||
});
|
const setting = loadThemeSetting();
|
||||||
}
|
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
|
||||||
$(function() {
|
},
|
||||||
$('li').click(function() {
|
themeChanged(event) {
|
||||||
url = $(this).attr('data-url');
|
const newSetting = $(event.currentTarget).val().toLowerCase();
|
||||||
if (url) {
|
saveThemeSetting(newSetting);
|
||||||
$(location).attr('href', url);
|
setTheme();
|
||||||
}
|
},
|
||||||
});
|
scan() {
|
||||||
});
|
if (this.scanning) return;
|
||||||
|
this.scanning = true;
|
||||||
|
this.scanMs = -1;
|
||||||
|
this.scanTitles = 0;
|
||||||
|
$.post(`${base_url}api/admin/scan`)
|
||||||
|
.then(data => {
|
||||||
|
this.scanMs = data.milliseconds;
|
||||||
|
this.scanTitles = data.titles;
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.scanning = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generateThumbnails() {
|
||||||
|
if (this.generating) return;
|
||||||
|
this.generating = true;
|
||||||
|
this.progress = 0.0;
|
||||||
|
$.post(`${base_url}api/admin/generate_thumbnails`)
|
||||||
|
.then(() => {
|
||||||
|
this.getProgress()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getProgress() {
|
||||||
|
$.get(`${base_url}api/admin/thumbnail_progress`)
|
||||||
|
.then(data => {
|
||||||
|
this.progress = data.progress;
|
||||||
|
this.generating = data.progress > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* --- Alpine helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an alpine.js property
|
||||||
|
*
|
||||||
|
* @function setProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {*} prop - The data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
*/
|
||||||
|
const setProp = (key, prop, selector = '#root') => {
|
||||||
|
$(selector).get(0).__x.$data[key] = prop;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an alpine.js property
|
||||||
|
*
|
||||||
|
* @function getProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
* @return {*} The data property
|
||||||
|
*/
|
||||||
|
const getProp = (key, selector = '#root') => {
|
||||||
|
return $(selector).get(0).__x.$data[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --- Theme related functions
|
||||||
|
* Note: In the comments below we treat "theme" and "theme setting"
|
||||||
|
* differently. A theme can have only two values, either "dark" or
|
||||||
|
* "light", while a theme setting can have the third value "system".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the system setting prefers dark theme.
|
||||||
|
* from https://flaviocopes.com/javascript-detect-dark-mode/
|
||||||
|
*
|
||||||
|
* @function preferDarkMode
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const preferDarkMode = () => {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given string represents a valid theme setting
|
||||||
|
*
|
||||||
|
* @function validThemeSetting
|
||||||
|
* @param {string} theme - The string representing the theme setting
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const validThemeSetting = (theme) => {
|
||||||
|
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load theme setting from local storage, or use 'light'
|
||||||
|
*
|
||||||
|
* @function loadThemeSetting
|
||||||
|
* @return {string} A theme setting ('dark', 'light', or 'system')
|
||||||
|
*/
|
||||||
|
const loadThemeSetting = () => {
|
||||||
|
let str = localStorage.getItem('theme');
|
||||||
|
if (!str || !validThemeSetting(str)) str = 'system';
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current theme (not theme setting)
|
||||||
|
*
|
||||||
|
* @function loadTheme
|
||||||
|
* @return {string} The current theme to use ('dark' or 'light')
|
||||||
|
*/
|
||||||
|
const loadTheme = () => {
|
||||||
|
let setting = loadThemeSetting();
|
||||||
|
if (setting === 'system') {
|
||||||
|
setting = preferDarkMode() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a theme setting
|
||||||
|
*
|
||||||
|
* @function saveThemeSetting
|
||||||
|
* @param {string} setting - A theme setting
|
||||||
|
*/
|
||||||
|
const saveThemeSetting = setting => {
|
||||||
|
if (!validThemeSetting(setting)) setting = 'system';
|
||||||
|
localStorage.setItem('theme', setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the current theme. When the current theme setting is 'system', it
|
||||||
|
* will be changed to either 'light' or 'dark'
|
||||||
|
*
|
||||||
|
* @function toggleTheme
|
||||||
|
*/
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = loadTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
saveThemeSetting(newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a theme, or load a theme and then apply it
|
||||||
|
*
|
||||||
|
* @function setTheme
|
||||||
|
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
|
||||||
|
* `loadTheme` to get a theme and apply it.
|
||||||
|
*/
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (!theme) theme = loadTheme();
|
||||||
|
if (theme === 'dark') {
|
||||||
|
$('html').css('background', 'rgb(20, 20, 20)');
|
||||||
|
$('body').addClass('uk-light');
|
||||||
|
$('.uk-card').addClass('uk-card-secondary');
|
||||||
|
$('.uk-card').removeClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').addClass('dark');
|
||||||
|
} else {
|
||||||
|
$('html').css('background', '');
|
||||||
|
$('body').removeClass('uk-light');
|
||||||
|
$('.uk-card').removeClass('uk-card-secondary');
|
||||||
|
$('.uk-card').addClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').removeClass('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// do it before document is ready to prevent the initial flash of white on
|
||||||
|
// most pages
|
||||||
|
setTheme();
|
||||||
|
$(() => {
|
||||||
|
// hack for the reader page
|
||||||
|
setTheme();
|
||||||
|
|
||||||
|
// on system dark mode setting change
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', event => {
|
||||||
|
if (loadThemeSetting() === 'system')
|
||||||
|
setTheme(event.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Truncate a .uk-card-title element
|
||||||
|
*
|
||||||
|
* @function truncate
|
||||||
|
* @param {object} e - The title element to truncate
|
||||||
|
*/
|
||||||
|
const truncate = (e) => {
|
||||||
|
$(e).dotdotdot({
|
||||||
|
truncate: 'letter',
|
||||||
|
watch: true,
|
||||||
|
callback: (truncated) => {
|
||||||
|
if (truncated) {
|
||||||
|
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||||
|
} else {
|
||||||
|
$(e).removeAttr('uk-tooltip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$('.uk-card-title').each((i, e) => {
|
||||||
|
// Truncate the title when it first enters the view
|
||||||
|
$(e).one('inview', () => {
|
||||||
|
truncate(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-135
@@ -1,138 +1,116 @@
|
|||||||
$(() => {
|
const component = () => {
|
||||||
$('input.uk-checkbox').each((i, e) => {
|
return {
|
||||||
$(e).change(() => {
|
jobs: [],
|
||||||
loadConfig();
|
paused: undefined,
|
||||||
});
|
loading: false,
|
||||||
});
|
toggling: false,
|
||||||
loadConfig();
|
ws: undefined,
|
||||||
load();
|
|
||||||
|
|
||||||
const intervalMS = 5000;
|
wsConnect(secure = true) {
|
||||||
setTimeout(() => {
|
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
|
||||||
setInterval(() => {
|
console.log(`Connecting to ${url}`);
|
||||||
if (globalConfig.autoRefresh !== true) return;
|
this.ws = new WebSocket(url);
|
||||||
load();
|
this.ws.onmessage = event => {
|
||||||
}, intervalMS);
|
const data = JSON.parse(event.data);
|
||||||
}, intervalMS);
|
this.jobs = data.jobs;
|
||||||
});
|
this.paused = data.paused;
|
||||||
var globalConfig = {};
|
};
|
||||||
var loading = false;
|
this.ws.onclose = () => {
|
||||||
|
if (this.ws.failed)
|
||||||
const loadConfig = () => {
|
return this.wsConnect(false);
|
||||||
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
alert('danger', 'Socket connection closed');
|
||||||
};
|
};
|
||||||
const remove = (id) => {
|
this.ws.onerror = () => {
|
||||||
var url = '/api/admin/mangadex/queue/delete';
|
if (secure)
|
||||||
if (id !== undefined)
|
return this.ws.failed = true;
|
||||||
url += '?' + $.param({id: id});
|
alert('danger', 'Socket connection failed');
|
||||||
console.log(url);
|
};
|
||||||
$.ajax({
|
},
|
||||||
type: 'POST',
|
init() {
|
||||||
url: url,
|
this.wsConnect();
|
||||||
dataType: 'json'
|
this.load();
|
||||||
})
|
},
|
||||||
.done(data => {
|
load() {
|
||||||
if (!data.success && data.error) {
|
this.loading = true;
|
||||||
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
$.ajax({
|
||||||
return;
|
type: 'GET',
|
||||||
|
url: base_url + 'api/admin/mangadex/queue',
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.jobs = data.jobs;
|
||||||
|
this.paused = data.paused;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
jobAction(action, event) {
|
||||||
|
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
|
if (event) {
|
||||||
|
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
||||||
|
url = `${url}?${$.param({
|
||||||
|
id: id
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
console.log(url);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
this.toggling = true;
|
||||||
|
const action = this.paused ? 'resume' : 'pause';
|
||||||
|
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.load();
|
||||||
|
this.toggling = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
statusClass(status) {
|
||||||
|
let cls = 'label ';
|
||||||
|
switch (status) {
|
||||||
|
case 'Pending':
|
||||||
|
cls += 'label-pending';
|
||||||
|
break;
|
||||||
|
case 'Completed':
|
||||||
|
cls += 'label-success';
|
||||||
|
break;
|
||||||
|
case 'Error':
|
||||||
|
cls += 'label-danger';
|
||||||
|
break;
|
||||||
|
case 'MissingPages':
|
||||||
|
cls += 'label-warning';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return cls;
|
||||||
}
|
}
|
||||||
load();
|
};
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const refresh = (id) => {
|
|
||||||
var url = '/api/admin/mangadex/queue/retry';
|
|
||||||
if (id !== undefined)
|
|
||||||
url += '?' + $.param({id: id});
|
|
||||||
console.log(url);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: url,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (!data.success && data.error) {
|
|
||||||
alert('danger', `Failed to restart download job. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toggle = () => {
|
|
||||||
$('#pause-resume-btn').attr('disabled', '');
|
|
||||||
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
|
||||||
const action = paused ? 'resume' : 'pause';
|
|
||||||
const url = `/api/admin/mangadex/queue/${action}`;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: url,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
load();
|
|
||||||
$('#pause-resume-btn').removeAttr('disabled');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const load = () => {
|
|
||||||
if (loading) return;
|
|
||||||
loading = true;
|
|
||||||
console.log('fetching');
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: '/api/admin/mangadex/queue',
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (!data.success && data.error) {
|
|
||||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(data);
|
|
||||||
const btnText = data.paused ? "Resume download" : "Pause download";
|
|
||||||
$('#pause-resume-btn').text(btnText);
|
|
||||||
$('#pause-resume-btn').removeAttr('hidden');
|
|
||||||
const rows = data.jobs.map(obj => {
|
|
||||||
var cls = 'uk-label ';
|
|
||||||
if (obj.status === 'Completed')
|
|
||||||
cls += 'uk-label-success';
|
|
||||||
if (obj.status === 'Error')
|
|
||||||
cls += 'uk-label-danger';
|
|
||||||
if (obj.status === 'MissingPages')
|
|
||||||
cls += 'uk-label-warning';
|
|
||||||
|
|
||||||
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
|
||||||
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
|
||||||
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
|
|
||||||
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
|
|
||||||
return `<tr id="chapter-${obj.id}">
|
|
||||||
<td><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td>
|
|
||||||
<td><a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a></td>
|
|
||||||
<td>${obj.success_count}/${obj.pages}</td>
|
|
||||||
<td>${moment(obj.time).fromNow()}</td>
|
|
||||||
<td>${statusSpan} ${dropdown}</td>
|
|
||||||
<td>
|
|
||||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
|
||||||
${retryBtn}
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
+51
-45
@@ -32,42 +32,41 @@ const download = () => {
|
|||||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
console.log(ids);
|
console.log(ids);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: '/api/admin/mangadex/download',
|
url: base_url + 'api/admin/mangadex/download',
|
||||||
data: JSON.stringify({chapters: chapters}),
|
data: JSON.stringify({
|
||||||
contentType: "application/json",
|
chapters: chapters
|
||||||
dataType: 'json'
|
}),
|
||||||
})
|
contentType: "application/json",
|
||||||
.done(data => {
|
dataType: 'json'
|
||||||
console.log(data);
|
})
|
||||||
if (data.error) {
|
.done(data => {
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
console.log(data);
|
||||||
return;
|
if (data.error) {
|
||||||
}
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
const successCount = parseInt(data.success);
|
return;
|
||||||
const failCount = parseInt(data.fail);
|
}
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
const successCount = parseInt(data.success);
|
||||||
window.location.href = '/admin/downloads';
|
const failCount = parseInt(data.fail);
|
||||||
|
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||||
|
window.location.href = base_url + 'admin/downloads';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
});
|
});
|
||||||
styleModal();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
styleModal();
|
|
||||||
};
|
};
|
||||||
const toggleSpinner = () => {
|
const toggleSpinner = () => {
|
||||||
var attr = $('#spinner').attr('hidden');
|
var attr = $('#spinner').attr('hidden');
|
||||||
if (attr) {
|
if (attr) {
|
||||||
$('#spinner').removeAttr('hidden');
|
$('#spinner').removeAttr('hidden');
|
||||||
$('#search-btn').attr('hidden', '');
|
$('#search-btn').attr('hidden', '');
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$('#search-btn').removeAttr('hidden');
|
$('#search-btn').removeAttr('hidden');
|
||||||
$('#spinner').attr('hidden', '');
|
$('#spinner').attr('hidden', '');
|
||||||
}
|
}
|
||||||
@@ -96,10 +95,9 @@ const search = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const path = new URL(input).pathname;
|
const path = new URL(input).pathname;
|
||||||
const match = /\/title\/([0-9]+)/.exec(path);
|
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||||
int_id = parseInt(match[1]);
|
int_id = parseInt(match[1]);
|
||||||
}
|
} catch (e) {
|
||||||
catch(e) {
|
|
||||||
int_id = parseInt(input);
|
int_id = parseInt(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +107,7 @@ const search = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$.getJSON("/api/admin/mangadex/manga/" + int_id)
|
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||||
.done((data) => {
|
.done((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||||
@@ -139,8 +137,12 @@ const search = () => {
|
|||||||
const comp = (a, b) => {
|
const comp = (a, b) => {
|
||||||
var ai;
|
var ai;
|
||||||
var bi;
|
var bi;
|
||||||
try {ai = parseFloat(a);} catch(e) {}
|
try {
|
||||||
try {bi = parseFloat(b);} catch(e) {}
|
ai = parseFloat(a);
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
bi = parseFloat(b);
|
||||||
|
} catch (e) {}
|
||||||
if (typeof ai === 'undefined') return -1;
|
if (typeof ai === 'undefined') return -1;
|
||||||
if (typeof bi === 'undefined') return 1;
|
if (typeof bi === 'undefined') return 1;
|
||||||
if (ai < bi) return 1;
|
if (ai < bi) return 1;
|
||||||
@@ -176,8 +178,7 @@ const parseRange = str => {
|
|||||||
if (!matches) {
|
if (!matches) {
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
// e.g., <= 30
|
||||||
num = parseInt(matches[2]);
|
num = parseInt(matches[2]);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
@@ -194,8 +195,7 @@ const parseRange = str => {
|
|||||||
case '>=':
|
case '>=':
|
||||||
return [num, null];
|
return [num, null];
|
||||||
}
|
}
|
||||||
}
|
} else if (typeof matches[3] !== 'undefined') {
|
||||||
else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
// a single number
|
||||||
num = parseInt(matches[3]);
|
num = parseInt(matches[3]);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
@@ -203,8 +203,7 @@ const parseRange = str => {
|
|||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
return [num, num];
|
return [num, num];
|
||||||
}
|
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
// e.g., 10 - 23
|
||||||
num = parseInt(matches[4]);
|
num = parseInt(matches[4]);
|
||||||
const n2 = parseInt(matches[5]);
|
const n2 = parseInt(matches[5]);
|
||||||
@@ -213,8 +212,7 @@ const parseRange = str => {
|
|||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
return [num, n2];
|
return [num, n2];
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// empty or space only
|
// empty or space only
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@@ -242,7 +240,10 @@ const buildTable = () => {
|
|||||||
Object.entries(filters).forEach(([k, v]) => {
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
if (v === 'All') return;
|
if (v === 'All') return;
|
||||||
if (k === 'group') {
|
if (k === 'group') {
|
||||||
chapters = chapters.filter(c => v in c.groups);
|
chapters = chapters.filter(c => {
|
||||||
|
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
||||||
|
return unescaped_groups.indexOf(v) >= 0;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (k === 'lang') {
|
if (k === 'lang') {
|
||||||
@@ -277,8 +278,7 @@ const buildTable = () => {
|
|||||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||||
}).join(' | ');
|
}).join(' | ');
|
||||||
const dark = getTheme() === 'dark' ? 'dark' : '';
|
return `<tr class="ui-widget-content">
|
||||||
return `<tr class="ui-widget-content ${dark}">
|
|
||||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||||
<td>${chp.title}</td>
|
<td>${chp.title}</td>
|
||||||
<td>${chp.language}</td>
|
<td>${chp.language}</td>
|
||||||
@@ -297,3 +297,9 @@ const buildTable = () => {
|
|||||||
});
|
});
|
||||||
$('#selection-controls').removeAttr('hidden');
|
$('#selection-controls').removeAttr('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unescapeHTML = (str) => {
|
||||||
|
var elt = document.createElement("span");
|
||||||
|
elt.innerHTML = str;
|
||||||
|
return elt.innerText;
|
||||||
|
};
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,141 @@
|
|||||||
|
const loadPlugin = id => {
|
||||||
|
localStorage.setItem('plugin', id);
|
||||||
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
|
const newURL = `${url}?${$.param({
|
||||||
|
plugin: id
|
||||||
|
})}`;
|
||||||
|
window.location.href = newURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
var storedID = localStorage.getItem('plugin');
|
||||||
|
if (storedID && storedID !== pid) {
|
||||||
|
loadPlugin(storedID);
|
||||||
|
} else {
|
||||||
|
$('#controls').removeAttr('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#search-input').keypress(event => {
|
||||||
|
if (event.which === 13) {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#plugin-select').val(pid);
|
||||||
|
$('#plugin-select').change(() => {
|
||||||
|
const id = $('#plugin-select').val();
|
||||||
|
loadPlugin(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mangaTitle = "";
|
||||||
|
let searching = false;
|
||||||
|
const search = () => {
|
||||||
|
if (searching)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const query = $.param({
|
||||||
|
query: $('#search-input').val(),
|
||||||
|
plugin: pid
|
||||||
|
});
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Search failed. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mangaTitle = data.title;
|
||||||
|
$('#title-text').text(data.title);
|
||||||
|
buildTable(data.chapters);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTable = (chapters) => {
|
||||||
|
$('#table').attr('hidden', '');
|
||||||
|
$('table').empty();
|
||||||
|
|
||||||
|
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||||
|
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||||
|
$('table').append(thead);
|
||||||
|
|
||||||
|
const rows = chapters.map(ch => {
|
||||||
|
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||||
|
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||||
|
});
|
||||||
|
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||||
|
$('table').append(tbody);
|
||||||
|
|
||||||
|
$('#selectable').selectable({
|
||||||
|
filter: 'tr'
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#table table').tablesorter();
|
||||||
|
$('#table').removeAttr('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unselect = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
const selected = $('tbody > tr.ui-selected');
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
$('#download-btn').attr('hidden', '');
|
||||||
|
$('#download-spinner').removeAttr('hidden');
|
||||||
|
const chapters = selected.map((i, e) => {
|
||||||
|
return {
|
||||||
|
id: $(e).attr('data-id'),
|
||||||
|
title: $(e).attr('data-title')
|
||||||
|
}
|
||||||
|
}).get();
|
||||||
|
console.log(chapters);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: base_url + 'api/admin/plugin/download',
|
||||||
|
data: JSON.stringify({
|
||||||
|
plugin: pid,
|
||||||
|
chapters: chapters,
|
||||||
|
title: mangaTitle
|
||||||
|
}),
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||||
|
window.location.href = base_url + 'admin/downloads';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
+273
-73
@@ -1,81 +1,281 @@
|
|||||||
$(function() {
|
const readerComponent = () => {
|
||||||
function bind() {
|
return {
|
||||||
var controller = new ScrollMagic.Controller();
|
loading: true,
|
||||||
|
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
|
||||||
|
msg: 'Loading the web reader. Please wait...',
|
||||||
|
alertClass: 'uk-alert-primary',
|
||||||
|
items: [],
|
||||||
|
curItem: {},
|
||||||
|
flipAnimation: null,
|
||||||
|
longPages: false,
|
||||||
|
lastSavedPage: page,
|
||||||
|
|
||||||
// replace history on scroll
|
/**
|
||||||
$('img').each(function(idx){
|
* Initialize the component by fetching the page dimensions
|
||||||
var scene = new ScrollMagic.Scene({
|
*/
|
||||||
triggerElement: $(this).get(),
|
init(nextTick) {
|
||||||
triggerHook: 'onEnter',
|
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
||||||
reverse: true
|
.then(data => {
|
||||||
})
|
if (!data.success && data.error)
|
||||||
.addTo(controller)
|
throw new Error(resp.error);
|
||||||
.on('enter', function(event){
|
const dimensions = data.dimensions;
|
||||||
current = $(event.target.triggerElement()).attr('id');
|
|
||||||
replaceHistory(current);
|
this.items = dimensions.map((d, i) => {
|
||||||
|
return {
|
||||||
|
id: i + 1,
|
||||||
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
|
width: d.width,
|
||||||
|
height: d.height,
|
||||||
|
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgRatio = this.items.reduce((acc, cur) => {
|
||||||
|
return acc + cur.height / cur.width
|
||||||
|
}, 0) / this.items.length;
|
||||||
|
|
||||||
|
console.log(avgRatio);
|
||||||
|
this.longPages = avgRatio > 2;
|
||||||
|
this.loading = false;
|
||||||
|
this.mode = localStorage.getItem('mode') || 'continuous';
|
||||||
|
|
||||||
|
// Here we save a copy of this.mode, and use the copy as
|
||||||
|
// the model-select value. This is because `updateMode`
|
||||||
|
// might change this.mode and make it `height` or `width`,
|
||||||
|
// which are not available in mode-select
|
||||||
|
const mode = this.mode;
|
||||||
|
this.updateMode(this.mode, page, nextTick);
|
||||||
|
$('#mode-select').val(mode);
|
||||||
})
|
})
|
||||||
.on('leave', function(event){
|
.catch(e => {
|
||||||
var prev = $(event.target.triggerElement()).prev();
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
current = $(prev).attr('id');
|
console.error(e);
|
||||||
replaceHistory(current);
|
this.alertClass = 'uk-alert-danger';
|
||||||
});
|
this.msg = errMsg;
|
||||||
});
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the page selector
|
||||||
|
*/
|
||||||
|
pageChanged() {
|
||||||
|
const p = parseInt($('#page-select').val());
|
||||||
|
this.toPage(p);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the mode selector
|
||||||
|
*
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
modeChanged(nextTick) {
|
||||||
|
const mode = $('#mode-select').val();
|
||||||
|
const curIdx = parseInt($('#page-select').val());
|
||||||
|
|
||||||
// poor man's infinite scroll
|
this.updateMode(mode, curIdx, nextTick);
|
||||||
var scene = new ScrollMagic.Scene({
|
},
|
||||||
triggerElement: $('.next-url').get(),
|
/**
|
||||||
triggerHook: 'onEnter',
|
* Handles the window `resize` event
|
||||||
offset: -500
|
*/
|
||||||
})
|
resized() {
|
||||||
.addTo(controller)
|
if (this.mode === 'continuous') return;
|
||||||
.on('enter', function(){
|
|
||||||
var nextURL = $('.next-url').attr('href');
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
$('.next-url').remove();
|
this.mode = wideScreen ? 'height' : 'width';
|
||||||
if (!nextURL) {
|
},
|
||||||
console.log('No .next-url found. Reached end of page');
|
/**
|
||||||
var lastURL = $('img').last().attr('id');
|
* Handles the window `keydown` event
|
||||||
// load the reader URL for the last page to update reading progrss to 100%
|
*
|
||||||
$.get(lastURL);
|
* @param {Event} event - The triggering event
|
||||||
$('#next-btn').removeAttr('hidden');
|
*/
|
||||||
return;
|
keyHandler(event) {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||||
|
this.flipPage(false);
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||||
|
this.flipPage(true);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Flips to the next or the previous page
|
||||||
|
*
|
||||||
|
* @param {bool} isNext - Whether we are going to the next page
|
||||||
|
*/
|
||||||
|
flipPage(isNext) {
|
||||||
|
const idx = parseInt(this.curItem.id);
|
||||||
|
const newIdx = idx + (isNext ? 1 : -1);
|
||||||
|
|
||||||
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
|
|
||||||
|
this.toPage(newIdx);
|
||||||
|
|
||||||
|
if (isNext)
|
||||||
|
this.flipAnimation = 'right';
|
||||||
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.flipAnimation = null;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.replaceHistory(newIdx);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Jumps to a specific page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
toPage(idx) {
|
||||||
|
if (this.mode === 'continuous') {
|
||||||
|
$(`#${idx}`).get(0).scrollIntoView(true);
|
||||||
|
} else {
|
||||||
|
if (idx >= 1 && idx <= this.items.length) {
|
||||||
|
this.curItem = this.items[idx - 1];
|
||||||
}
|
}
|
||||||
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){
|
}
|
||||||
if (status === 'error') console.log(xhr.statusText);
|
this.replaceHistory(idx);
|
||||||
if (status === 'success') {
|
UIkit.modal($('#modal-sections')).hide();
|
||||||
console.log(nextURL + ' loaded');
|
},
|
||||||
// new page loaded to #hidden, we now append it
|
/**
|
||||||
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
|
* Replace the address bar history and save the reading progress if necessary
|
||||||
$('#hidden').empty();
|
*
|
||||||
bind();
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
replaceHistory(idx) {
|
||||||
|
const ary = window.location.pathname.split('/');
|
||||||
|
ary[ary.length - 1] = idx;
|
||||||
|
ary.shift(); // remove leading `/`
|
||||||
|
ary.unshift(window.location.origin);
|
||||||
|
const url = ary.join('/');
|
||||||
|
this.saveProgress(idx);
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the backend reading progress if:
|
||||||
|
* 1) the current page is more than five pages away from the last
|
||||||
|
* saved page, or
|
||||||
|
* 2) the average height/width ratio of the pages is over 2, or
|
||||||
|
* 3) the current page is the first page, or
|
||||||
|
* 4) the current page is the last page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
* @param {function} cb - Callback
|
||||||
|
*/
|
||||||
|
saveProgress(idx, cb) {
|
||||||
|
idx = parseInt(idx);
|
||||||
|
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
|
||||||
|
this.longPages ||
|
||||||
|
idx === 1 || idx === this.items.length
|
||||||
|
) {
|
||||||
|
this.lastSavedPage = idx;
|
||||||
|
console.log('saving progress', idx);
|
||||||
|
|
||||||
|
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
||||||
|
$.ajax({
|
||||||
|
method: 'PUT',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error)
|
||||||
|
alert('danger', data.error);
|
||||||
|
if (cb) cb();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the reader mode
|
||||||
|
*
|
||||||
|
* @param {string} mode - Either `continuous` or `paged`
|
||||||
|
* @param {number} targetPage - The one-based index of the target page
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
updateMode(mode, targetPage, nextTick) {
|
||||||
|
localStorage.setItem('mode', mode);
|
||||||
|
|
||||||
|
// The mode to be put into the `mode` prop. It can't be `screen`
|
||||||
|
let propMode = mode;
|
||||||
|
|
||||||
|
if (mode === 'paged') {
|
||||||
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
|
propMode = wideScreen ? 'height' : 'width';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = propMode;
|
||||||
|
|
||||||
|
if (mode === 'continuous') {
|
||||||
|
nextTick(() => {
|
||||||
|
this.setupScroller();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
this.toPage(targetPage);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Shows the control modal
|
||||||
|
*
|
||||||
|
* @param {Event} event - The triggering event
|
||||||
|
*/
|
||||||
|
showControl(event) {
|
||||||
|
const idx = event.currentTarget.id;
|
||||||
|
const pageCount = this.items.length;
|
||||||
|
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||||
|
$('#progress-label').text(progressText);
|
||||||
|
$('#page-select').val(idx);
|
||||||
|
UIkit.modal($('#modal-sections')).show();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Redirects to a URL
|
||||||
|
*
|
||||||
|
* @param {string} url - The target URL
|
||||||
|
*/
|
||||||
|
redirect(url) {
|
||||||
|
window.location.replace(url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Set up the scroll handler that calls `replaceHistory` when an image
|
||||||
|
* enters the view port
|
||||||
|
*/
|
||||||
|
setupScroller() {
|
||||||
|
if (this.mode !== 'continuous') return;
|
||||||
|
$('img').each((idx, el) => {
|
||||||
|
$(el).on('inview', (event, inView) => {
|
||||||
|
if (inView) {
|
||||||
|
const current = $(event.currentTarget).attr('id');
|
||||||
|
|
||||||
|
this.curItem = this.items[current - 1];
|
||||||
|
this.replaceHistory(current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
/**
|
||||||
bind();
|
* Marks progress as 100% and jumps to the next entry
|
||||||
});
|
*
|
||||||
$('#page-select').change(function(){
|
* @param {string} nextUrl - URL of the next entry
|
||||||
jumpTo(parseInt($('#page-select').val()));
|
*/
|
||||||
});
|
nextEntry(nextUrl) {
|
||||||
function showControl(idx) {
|
this.saveProgress(this.items.length, () => {
|
||||||
const pageCount = $('#page-select > option').length;
|
this.redirect(nextUrl);
|
||||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
});
|
||||||
$('#progress-label').text(progressText);
|
},
|
||||||
$('#page-select').val(idx);
|
/**
|
||||||
UIkit.modal($('#modal-sections')).show();
|
* Exits the reader, and optionally sets the reading progress tp 100%
|
||||||
styleModal();
|
*
|
||||||
}
|
* @param {string} exitUrl - The Exit URL
|
||||||
function jumpTo(page) {
|
* @param {boolean} [markCompleted] - Whether we should mark the
|
||||||
var ary = window.location.pathname.split('/');
|
* reading progress to 100%
|
||||||
ary[ary.length - 1] = page;
|
*/
|
||||||
ary.shift(); // remove leading `/`
|
exitReader(exitUrl, markCompleted = false) {
|
||||||
ary.unshift(window.location.origin);
|
if (!markCompleted) {
|
||||||
window.location.replace(ary.join('/'));
|
return this.redirect(exitUrl);
|
||||||
}
|
}
|
||||||
function replaceHistory(url) {
|
this.saveProgress(this.items.length, () => {
|
||||||
history.replaceState(null, "", url);
|
this.redirect(exitUrl);
|
||||||
console.log('reading ' + url);
|
});
|
||||||
}
|
}
|
||||||
function redirect(url) {
|
};
|
||||||
window.location.replace(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
+7
-116
@@ -1,124 +1,15 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
const sortItems = () => {
|
$('#sort-select').change(() => {
|
||||||
const sort = $('#sort-select').find(':selected').attr('id');
|
const sort = $('#sort-select').find(':selected').attr('id');
|
||||||
const ary = sort.split('-');
|
const ary = sort.split('-');
|
||||||
const by = ary[0];
|
const by = ary[0];
|
||||||
const dir = ary[1];
|
const dir = ary[1];
|
||||||
|
|
||||||
let items = $('.item');
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
items.remove();
|
const newURL = `${url}?${$.param({
|
||||||
|
sort: by,
|
||||||
const ctxAry = [];
|
ascend: dir === 'up' ? 1 : 0
|
||||||
const keyRange = {};
|
})}`;
|
||||||
if (by === 'auto') {
|
window.location.href = newURL;
|
||||||
// intelligent sorting
|
|
||||||
items.each((i, item) => {
|
|
||||||
const name = $(item).find('.uk-card-title').text();
|
|
||||||
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
|
|
||||||
|
|
||||||
const numbers = {};
|
|
||||||
let match = regex.exec(name);
|
|
||||||
while (match) {
|
|
||||||
const key = match[1];
|
|
||||||
const num = parseFloat(match[2]);
|
|
||||||
numbers[key] = num;
|
|
||||||
|
|
||||||
if (!keyRange[key]) {
|
|
||||||
keyRange[key] = [num, num, 1];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
keyRange[key][2] += 1;
|
|
||||||
if (num < keyRange[key][0]) {
|
|
||||||
keyRange[key][0] = num;
|
|
||||||
}
|
|
||||||
else if (num > keyRange[key][1]) {
|
|
||||||
keyRange[key][1] = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match = regex.exec(name);
|
|
||||||
}
|
|
||||||
ctxAry.push({index: i, numbers: numbers});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(keyRange);
|
|
||||||
|
|
||||||
const sortedKeys = Object.keys(keyRange).filter(k => {
|
|
||||||
return keyRange[k][2] >= items.length / 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedKeys.sort((a, b) => {
|
|
||||||
// sort by frequency of the key first
|
|
||||||
if (keyRange[a][2] !== keyRange[b][2]) {
|
|
||||||
return keyRange[a][2] < keyRange[b][2];
|
|
||||||
}
|
|
||||||
// then sort by range of the key
|
|
||||||
return (keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(sortedKeys);
|
|
||||||
|
|
||||||
ctxAry.sort((a, b) => {
|
|
||||||
for (let i = 0; i < sortedKeys.length; i++) {
|
|
||||||
const key = sortedKeys[i];
|
|
||||||
|
|
||||||
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
|
|
||||||
continue;
|
|
||||||
if (a.numbers[key] === undefined)
|
|
||||||
return 1;
|
|
||||||
if (b.numbers[key] === undefined)
|
|
||||||
return -1;
|
|
||||||
if (a.numbers[key] === b.numbers[key])
|
|
||||||
continue;
|
|
||||||
return a.numbers[key] > b.numbers[key];
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedItems = [];
|
|
||||||
ctxAry.forEach(ctx => {
|
|
||||||
sortedItems.push(items[ctx.index]);
|
|
||||||
});
|
|
||||||
items = sortedItems;
|
|
||||||
|
|
||||||
if (dir === 'down') {
|
|
||||||
items.reverse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
items.sort((a, b) => {
|
|
||||||
var res;
|
|
||||||
if (by === 'name')
|
|
||||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
|
||||||
else if (by === 'date')
|
|
||||||
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
|
|
||||||
else if (by === 'progress') {
|
|
||||||
const ap = parseFloat($(a).attr('data-progress'));
|
|
||||||
const bp = parseFloat($(b).attr('data-progress'));
|
|
||||||
if (ap === bp)
|
|
||||||
// if progress is the same, we compare by name
|
|
||||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
|
||||||
else
|
|
||||||
res = ap > bp;
|
|
||||||
}
|
|
||||||
if (dir === 'up')
|
|
||||||
return res;
|
|
||||||
else
|
|
||||||
return !res;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var html = '';
|
|
||||||
$('#item-container').append(items);
|
|
||||||
};
|
|
||||||
|
|
||||||
$('#sort-select').change(() => {
|
|
||||||
sortItems();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($('option#auto-up').length > 0)
|
|
||||||
$('option#auto-up').attr('selected', '');
|
|
||||||
else
|
|
||||||
$('option#name-up').attr('selected', '');
|
|
||||||
|
|
||||||
sortItems();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
const getTheme = () => {
|
|
||||||
var theme = localStorage.getItem('theme');
|
|
||||||
if (!theme) theme = 'light';
|
|
||||||
return theme;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTheme = theme => {
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const theme = getTheme();
|
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
|
||||||
setTheme(newTheme);
|
|
||||||
saveTheme(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/28344281
|
|
||||||
const hasClass = (ele,cls) => {
|
|
||||||
return !!ele.className.match(new RegExp('(\\s|^)'+cls+'(\\s|$)'));
|
|
||||||
};
|
|
||||||
const addClass = (ele,cls) => {
|
|
||||||
if (!hasClass(ele,cls)) ele.className += " "+cls;
|
|
||||||
};
|
|
||||||
const removeClass = (ele,cls) => {
|
|
||||||
if (hasClass(ele,cls)) {
|
|
||||||
var reg = new RegExp('(\\s|^)'+cls+'(\\s|$)');
|
|
||||||
ele.className=ele.className.replace(reg,' ');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addClassToClass = (targetCls, newCls) => {
|
|
||||||
const elements = document.getElementsByClassName(targetCls);
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
|
||||||
addClass(elements[i], newCls);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeClassFromClass = (targetCls, newCls) => {
|
|
||||||
const elements = document.getElementsByClassName(targetCls);
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
|
||||||
removeClass(elements[i], newCls);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTheme = themeStr => {
|
|
||||||
if (themeStr === 'dark') {
|
|
||||||
document.getElementsByTagName('html')[0].style.background = 'rgb(20, 20, 20)';
|
|
||||||
addClass(document.getElementsByTagName('body')[0], 'uk-light');
|
|
||||||
addClassToClass('uk-card', 'uk-card-secondary');
|
|
||||||
removeClassFromClass('uk-card', 'uk-card-default');
|
|
||||||
addClassToClass('ui-widget-content', 'dark');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
document.getElementsByTagName('html')[0].style.background = '';
|
|
||||||
removeClass(document.getElementsByTagName('body')[0], 'uk-light');
|
|
||||||
removeClassFromClass('uk-card', 'uk-card-secondary');
|
|
||||||
addClassToClass('uk-card', 'uk-card-default');
|
|
||||||
removeClassFromClass('ui-widget-content', 'dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const styleModal = () => {
|
|
||||||
const color = getTheme() === 'dark' ? '#222' : '';
|
|
||||||
$('.uk-modal-header').css('background', color);
|
|
||||||
$('.uk-modal-body').css('background', color);
|
|
||||||
$('.uk-modal-footer').css('background', color);
|
|
||||||
};
|
|
||||||
|
|
||||||
// do it before document is ready to prevent the initial flash of white
|
|
||||||
setTheme(getTheme());
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// because this script is attached at the top of HTML, the style on uk-card
|
|
||||||
// won't be applied because the elements are not available yet. We have to
|
|
||||||
// apply the theme again for it to take effect
|
|
||||||
setTheme(getTheme());
|
|
||||||
}, false);
|
|
||||||
+311
-20
@@ -1,45 +1,336 @@
|
|||||||
|
$(() => {
|
||||||
|
setupAcard();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupAcard = () => {
|
||||||
|
$('.acard.is_entry').click((e) => {
|
||||||
|
if ($(e.target).hasClass('no-modal')) return;
|
||||||
|
const card = $(e.target).closest('.acard');
|
||||||
|
|
||||||
|
showModal(
|
||||||
|
$(card).attr('data-encoded-path'),
|
||||||
|
parseInt($(card).attr('data-pages')),
|
||||||
|
parseFloat($(card).attr('data-progress')),
|
||||||
|
$(card).attr('data-encoded-book-title'),
|
||||||
|
$(card).attr('data-encoded-title'),
|
||||||
|
$(card).attr('data-book-id'),
|
||||||
|
$(card).attr('data-id')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
||||||
const zipPath = decodeURIComponent(encodedPath);
|
const zipPath = decodeURIComponent(encodedPath);
|
||||||
const title = decodeURIComponent(encodedeTitle);
|
const title = decodeURIComponent(encodedeTitle);
|
||||||
const entry = decodeURIComponent(encodedEntryTitle);
|
const entry = decodeURIComponent(encodedEntryTitle);
|
||||||
$('#modal button, #modal a').each(function(){
|
$('#modal button, #modal a').each(function() {
|
||||||
$(this).removeAttr('hidden');
|
$(this).removeAttr('hidden');
|
||||||
});
|
});
|
||||||
if (percentage === 0) {
|
if (percentage === 0) {
|
||||||
$('#continue-btn').attr('hidden', '');
|
$('#continue-btn').attr('hidden', '');
|
||||||
$('#unread-btn').attr('hidden', '');
|
$('#unread-btn').attr('hidden', '');
|
||||||
}
|
} else if (percentage === 100) {
|
||||||
else {
|
$('#read-btn').attr('hidden', '');
|
||||||
|
$('#continue-btn').attr('hidden', '');
|
||||||
|
} else {
|
||||||
$('#continue-btn').text('Continue from ' + percentage + '%');
|
$('#continue-btn').text('Continue from ' + percentage + '%');
|
||||||
}
|
}
|
||||||
if (percentage === 100) {
|
|
||||||
$('#read-btn').attr('hidden', '');
|
$('#modal-entry-title').find('span').text(entry);
|
||||||
}
|
$('#modal-entry-title').next().attr('data-id', titleID);
|
||||||
$('#modal-title').text(entry);
|
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
||||||
|
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
|
||||||
$('#path-text').text(zipPath);
|
$('#path-text').text(zipPath);
|
||||||
$('#pages-text').text(pages + ' pages');
|
$('#pages-text').text(pages + ' pages');
|
||||||
|
|
||||||
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
|
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
|
||||||
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
|
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
|
||||||
|
|
||||||
$('#read-btn').click(function(){
|
$('#read-btn').click(function() {
|
||||||
updateProgress(titleID, entryID, pages);
|
updateProgress(titleID, entryID, pages);
|
||||||
});
|
});
|
||||||
$('#unread-btn').click(function(){
|
$('#unread-btn').click(function() {
|
||||||
updateProgress(titleID, entryID, 0);
|
updateProgress(titleID, entryID, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
||||||
|
|
||||||
|
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
|
||||||
|
|
||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
styleModal();
|
|
||||||
}
|
}
|
||||||
function updateProgress(titleID, entryID, page) {
|
|
||||||
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
|
const updateProgress = (tid, eid, page) => {
|
||||||
if (data.success) {
|
let url = `${base_url}api/progress/${tid}/${page}`
|
||||||
|
const query = $.param({
|
||||||
|
eid: eid
|
||||||
|
});
|
||||||
|
if (eid)
|
||||||
|
url += `?${query}`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
method: 'PUT',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
error = data.error;
|
||||||
|
alert('danger', error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameSubmit = (name, eid) => {
|
||||||
|
const upload = $('.upload-field');
|
||||||
|
const titleId = upload.attr('data-title-id');
|
||||||
|
|
||||||
|
console.log(name);
|
||||||
|
|
||||||
|
if (name.length === 0) {
|
||||||
|
alert('danger', 'The display name should not be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = $.param({
|
||||||
|
eid: eid
|
||||||
|
});
|
||||||
|
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||||
|
if (eid)
|
||||||
|
url += `?${query}`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
type: 'PUT',
|
||||||
|
url: url,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to update display name. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
})
|
||||||
else {
|
.fail((jqXHR, status) => {
|
||||||
error = data.error;
|
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
alert('danger', error);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = (eid) => {
|
||||||
|
const cover = $('#edit-modal #cover');
|
||||||
|
let url = cover.attr('data-title-cover');
|
||||||
|
let displayName = $('h2.uk-title > span').text();
|
||||||
|
|
||||||
|
if (eid) {
|
||||||
|
const item = $(`#${eid}`);
|
||||||
|
url = item.find('img').attr('data-src');
|
||||||
|
displayName = item.find('.uk-card-title').attr('data-title');
|
||||||
|
$('#title-progress-control').attr('hidden', '');
|
||||||
|
} else {
|
||||||
|
$('#title-progress-control').removeAttr('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
cover.attr('data-src', url);
|
||||||
|
|
||||||
|
const displayNameField = $('#display-name-field');
|
||||||
|
displayNameField.attr('value', displayName);
|
||||||
|
console.log(displayNameField);
|
||||||
|
displayNameField.keyup(event => {
|
||||||
|
if (event.keyCode === 13) {
|
||||||
|
renameSubmit(displayNameField.val(), eid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
displayNameField.siblings('a.uk-form-icon').click(() => {
|
||||||
|
renameSubmit(displayNameField.val(), eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
setupUpload(eid);
|
||||||
|
|
||||||
|
UIkit.modal($('#edit-modal')).show();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupUpload = (eid) => {
|
||||||
|
const upload = $('.upload-field');
|
||||||
|
const bar = $('#upload-progress').get(0);
|
||||||
|
const titleId = upload.attr('data-title-id');
|
||||||
|
const queryObj = {
|
||||||
|
tid: titleId
|
||||||
|
};
|
||||||
|
if (eid)
|
||||||
|
queryObj['eid'] = eid;
|
||||||
|
const query = $.param(queryObj);
|
||||||
|
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||||
|
console.log(url);
|
||||||
|
UIkit.upload('.upload-field', {
|
||||||
|
url: url,
|
||||||
|
name: 'file',
|
||||||
|
error: (e) => {
|
||||||
|
alert('danger', `Failed to upload cover image: ${e.toString()}`);
|
||||||
|
},
|
||||||
|
loadStart: (e) => {
|
||||||
|
$(bar).removeAttr('hidden');
|
||||||
|
bar.max = e.total;
|
||||||
|
bar.value = e.loaded;
|
||||||
|
},
|
||||||
|
progress: (e) => {
|
||||||
|
bar.max = e.total;
|
||||||
|
bar.value = e.loaded;
|
||||||
|
},
|
||||||
|
loadEnd: (e) => {
|
||||||
|
bar.max = e.total;
|
||||||
|
bar.value = e.loaded;
|
||||||
|
},
|
||||||
|
completeAll: () => {
|
||||||
|
$(bar).attr('hidden', '');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
data['selected'] = false;
|
||||||
|
});
|
||||||
|
$('#select-bar')[0].__x.$data['count'] = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
let count = 0;
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
if (!data['disabled']) {
|
||||||
|
data['selected'] = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#select-bar')[0].__x.$data['count'] = count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedIDs = () => {
|
||||||
|
const ary = [];
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
if (!data['disabled'] && data['selected']) {
|
||||||
|
const item = $(e).closest('.item');
|
||||||
|
ary.push($(item).attr('id'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkProgress = (action, el) => {
|
||||||
|
const tid = $(el).attr('data-id');
|
||||||
|
const ids = selectedIDs();
|
||||||
|
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
|
||||||
|
$.ajax({
|
||||||
|
type: 'PUT',
|
||||||
|
url: url,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json',
|
||||||
|
data: JSON.stringify({
|
||||||
|
ids: ids
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
deselectAll();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagsComponent = () => {
|
||||||
|
return {
|
||||||
|
isAdmin: false,
|
||||||
|
tags: [],
|
||||||
|
tid: $('.upload-field').attr('data-title-id'),
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
load(admin) {
|
||||||
|
this.isAdmin = admin;
|
||||||
|
|
||||||
|
$('.tag-select').select2({
|
||||||
|
tags: true,
|
||||||
|
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
|
||||||
|
disabled: !this.isAdmin,
|
||||||
|
templateSelection(state) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
|
||||||
|
a.setAttribute('class', 'uk-link-reset');
|
||||||
|
a.onclick = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
a.innerText = state.text;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.request(`${base_url}api/tags`, 'GET', (data) => {
|
||||||
|
const allTags = data.tags;
|
||||||
|
const url = `${base_url}api/tags/${this.tid}`;
|
||||||
|
this.request(url, 'GET', data => {
|
||||||
|
this.tags = data.tags;
|
||||||
|
allTags.forEach(t => {
|
||||||
|
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
|
||||||
|
$('.tag-select').append(op);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:select', e => {
|
||||||
|
this.onAdd(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:unselect', e => {
|
||||||
|
this.onDelete(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('change', () => {
|
||||||
|
this.onChange();
|
||||||
|
});
|
||||||
|
$('.tag-select').trigger('change');
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChange() {
|
||||||
|
this.tags = $('.tag-select').select2('data').map(o => o.text);
|
||||||
|
},
|
||||||
|
onAdd(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'PUT');
|
||||||
|
},
|
||||||
|
onDelete(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'DELETE');
|
||||||
|
},
|
||||||
|
request(url, method, cb) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.success) {
|
||||||
|
if (cb) cb(data);
|
||||||
|
} else {
|
||||||
|
alert('danger', data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
var target = '/admin/user/edit';
|
var target = base_url + 'admin/user/edit';
|
||||||
if (username) target += username;
|
if (username) target += username;
|
||||||
$('form').attr('action', target);
|
$('form').attr('action', target);
|
||||||
if (error) alert('danger', error);
|
if (error) alert('danger', error);
|
||||||
|
|||||||
+16
-11
@@ -1,11 +1,16 @@
|
|||||||
function remove(username) {
|
const remove = (username) => {
|
||||||
$.post('/api/admin/user/delete/' + username, function(data) {
|
$.ajax({
|
||||||
if (data.success) {
|
url: `${base_url}api/admin/user/delete/${username}`,
|
||||||
location.reload();
|
type: 'DELETE',
|
||||||
}
|
dataType: 'json'
|
||||||
else {
|
})
|
||||||
error = data.error;
|
.done(data => {
|
||||||
alert('danger', error);
|
if (data.success)
|
||||||
}
|
location.reload();
|
||||||
});
|
else
|
||||||
}
|
alert('danger', data.error);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
+69
-21
@@ -1,30 +1,78 @@
|
|||||||
version: 1.0
|
version: 2.0
|
||||||
shards:
|
shards:
|
||||||
db:
|
ameba:
|
||||||
github: crystal-lang/crystal-db
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 0.8.0
|
version: 0.12.1
|
||||||
|
|
||||||
exception_page:
|
archive:
|
||||||
github: crystal-loot/exception_page
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
version: 0.1.2
|
|
||||||
|
|
||||||
kemal:
|
|
||||||
github: kemalcr/kemal
|
|
||||||
version: 0.26.1
|
|
||||||
|
|
||||||
kemal-basic-auth:
|
|
||||||
github: kemalcr/kemal-basic-auth
|
|
||||||
version: 0.2.0
|
|
||||||
|
|
||||||
kilt:
|
|
||||||
github: jeromegn/kilt
|
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
|
|
||||||
|
baked_file_system:
|
||||||
|
git: https://github.com/schovi/baked_file_system.git
|
||||||
|
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||||
|
|
||||||
|
clim:
|
||||||
|
git: https://github.com/at-grandpa/clim.git
|
||||||
|
version: 0.12.0
|
||||||
|
|
||||||
|
db:
|
||||||
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
|
version: 0.9.0
|
||||||
|
|
||||||
|
duktape:
|
||||||
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
|
version: 0.20.0
|
||||||
|
|
||||||
|
exception_page:
|
||||||
|
git: https://github.com/crystal-loot/exception_page.git
|
||||||
|
version: 0.1.4
|
||||||
|
|
||||||
|
http_proxy:
|
||||||
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
|
version: 0.7.1
|
||||||
|
|
||||||
|
image_size:
|
||||||
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
|
version: 0.4.0
|
||||||
|
|
||||||
|
kemal:
|
||||||
|
git: https://github.com/kemalcr/kemal.git
|
||||||
|
version: 0.27.0
|
||||||
|
|
||||||
|
kemal-session:
|
||||||
|
git: https://github.com/kemalcr/kemal-session.git
|
||||||
|
version: 0.12.1
|
||||||
|
|
||||||
|
kilt:
|
||||||
|
git: https://github.com/jeromegn/kilt.git
|
||||||
|
version: 0.4.0
|
||||||
|
|
||||||
|
koa:
|
||||||
|
git: https://github.com/hkalexling/koa.git
|
||||||
|
version: 0.5.0
|
||||||
|
|
||||||
|
mg:
|
||||||
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
||||||
|
|
||||||
|
myhtml:
|
||||||
|
git: https://github.com/kostya/myhtml.git
|
||||||
|
version: 1.5.1
|
||||||
|
|
||||||
|
open_api:
|
||||||
|
git: https://github.com/jreinert/open_api.cr.git
|
||||||
|
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
github: luislavena/radix
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.3.9
|
version: 0.3.9
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.15.0
|
version: 0.16.0
|
||||||
|
|
||||||
|
tallboy:
|
||||||
|
git: https://github.com/epoch/tallboy.git
|
||||||
|
version: 0.9.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.2.3
|
version: 0.19.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -8,14 +8,38 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 0.32.1
|
crystal: 0.35.1
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
kemal:
|
kemal:
|
||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
|
kemal-session:
|
||||||
|
github: kemalcr/kemal-session
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
|
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||||
|
archive:
|
||||||
|
github: hkalexling/archive.cr
|
||||||
|
ameba:
|
||||||
|
github: crystal-ameba/ameba
|
||||||
|
clim:
|
||||||
|
github: at-grandpa/clim
|
||||||
|
duktape:
|
||||||
|
github: jessedoyle/duktape.cr
|
||||||
|
version: ~> 0.20.0
|
||||||
|
myhtml:
|
||||||
|
github: kostya/myhtml
|
||||||
|
http_proxy:
|
||||||
|
github: mamantoha/http_proxy
|
||||||
|
image_size:
|
||||||
|
github: hkalexling/image_size.cr
|
||||||
|
koa:
|
||||||
|
github: hkalexling/koa
|
||||||
|
tallboy:
|
||||||
|
github: epoch/tallboy
|
||||||
|
mg:
|
||||||
|
github: hkalexling/mg
|
||||||
|
|||||||
+9
-9
@@ -1,14 +1,14 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Config do
|
describe Config do
|
||||||
it "creates config if it does not exist" do
|
it "creates config if it does not exist" do
|
||||||
with_default_config do |config, logger, path|
|
with_default_config do |_, path|
|
||||||
File.exists?(path).should be_true
|
File.exists?(path).should be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "correctly loads config" do
|
it "correctly loads config" do
|
||||||
config = Config.load "spec/asset/test-config.yml"
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
config.port.should eq 3000
|
config.port.should eq 3000
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
require "./spec_helper"
|
|
||||||
|
|
||||||
include MangaDex
|
|
||||||
|
|
||||||
describe Queue do
|
|
||||||
it "creates DB at given path" do
|
|
||||||
with_queue do |queue, path|
|
|
||||||
File.exists?(path).should be_true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "pops nil when empty" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.pop.should be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "inserts multiple jobs" do
|
|
||||||
with_queue do |queue|
|
|
||||||
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
|
|
||||||
Time.utc
|
|
||||||
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
|
|
||||||
Time.utc
|
|
||||||
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
|
|
||||||
Time.utc
|
|
||||||
j4 = Job.new "4", "4", "title", "manga_title",
|
|
||||||
JobStatus::Downloading, Time.utc
|
|
||||||
count = queue.push [j1, j2, j3, j4]
|
|
||||||
count.should eq 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "pops pending job" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.id.should eq "3"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "correctly counts jobs" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.count.should eq 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "deletes job" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.delete "4"
|
|
||||||
queue.count.should eq 3
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "sets status" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.set_status JobStatus::Downloading, job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.status.should eq JobStatus::Downloading
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "sets number of pages" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.set_pages 100, job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.pages.should eq 100
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds fail/success counts" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.add_success job
|
|
||||||
queue.add_success job
|
|
||||||
queue.add_fail job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.success_count.should eq 2
|
|
||||||
job.not_nil!.fail_count.should eq 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "appends status message" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.add_message "hello", job
|
|
||||||
queue.add_message "world", job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.status_message.should eq "\nhello\nworld"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "cleans up" do
|
|
||||||
with_queue do
|
|
||||||
true
|
|
||||||
end
|
|
||||||
State.reset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
require "../src/rename"
|
||||||
|
|
||||||
|
include Rename
|
||||||
|
|
||||||
|
describe Rule do
|
||||||
|
it "raises on nested brackets" do
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "[[]]"
|
||||||
|
end
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "{{}}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises on unclosed brackets" do
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "["
|
||||||
|
end
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "{"
|
||||||
|
end
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "[{]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises when closing unopened brackets" do
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "]"
|
||||||
|
end
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "[}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles `|` in patterns" do
|
||||||
|
rule = Rule.new "{a|b|c}"
|
||||||
|
rule.render({"b" => "b"}).should eq "b"
|
||||||
|
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises on escaped characters" do
|
||||||
|
expect_raises Exception do
|
||||||
|
Rule.new "hello/world"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles spaces in patterns" do
|
||||||
|
rule = Rule.new "{ a }"
|
||||||
|
rule.render({"a" => "a"}).should eq "a"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips leading and tailing spaces" do
|
||||||
|
rule = Rule.new " hello "
|
||||||
|
rule.render({"a" => "a"}).should eq "hello"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders a few examples correctly" do
|
||||||
|
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
|
||||||
|
rule.render({"id" => "ID"}).should eq "ID testing"
|
||||||
|
rule.render({"chapter" => "CH", "id" => "ID"})
|
||||||
|
.should eq "Ch. CH ID testing"
|
||||||
|
rule.render({} of String => String).should eq "testing"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "escapes illegal characters" do
|
||||||
|
rule = Rule.new "{a}"
|
||||||
|
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips trailing spaces and dots" do
|
||||||
|
rule = Rule.new "hello. world. .."
|
||||||
|
rule.render({} of String => String).should eq "hello. world"
|
||||||
|
end
|
||||||
|
end
|
||||||
+38
-47
@@ -1,65 +1,56 @@
|
|||||||
require "spec"
|
require "spec"
|
||||||
require "../src/context"
|
require "../src/queue"
|
||||||
require "../src/server"
|
require "../src/server"
|
||||||
|
require "../src/config"
|
||||||
|
require "../src/main_fiber"
|
||||||
|
|
||||||
class State
|
class State
|
||||||
@@hash = {} of String => String
|
@@hash = {} of String => String
|
||||||
|
|
||||||
def self.get(key)
|
def self.get(key)
|
||||||
@@hash[key]?
|
@@hash[key]?
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.get!(key)
|
def self.get!(key)
|
||||||
@@hash[key]
|
@@hash[key]
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.set(key, value)
|
def self.set(key, value)
|
||||||
return if value.nil?
|
return if value.nil?
|
||||||
@@hash[key] = value
|
@@hash[key] = value
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.reset
|
def self.reset
|
||||||
@@hash.clear
|
@@hash.clear
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_tempfile(name)
|
def get_tempfile(name)
|
||||||
path = State.get name
|
path = State.get name
|
||||||
if path.nil? || !File.exists? path
|
if path.nil? || !File.exists? path
|
||||||
file = File.tempfile name
|
file = File.tempfile name
|
||||||
State.set name, file.path
|
State.set name, file.path
|
||||||
return file
|
file
|
||||||
else
|
else
|
||||||
return File.new path
|
File.new path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_default_config
|
def with_default_config
|
||||||
temp_config = get_tempfile "mango-test-config"
|
temp_config = get_tempfile "mango-test-config"
|
||||||
config = Config.load temp_config.path
|
config = Config.load temp_config.path
|
||||||
logger = MLogger.new config
|
config.set_current
|
||||||
yield config, logger, temp_config.path
|
yield config, temp_config.path
|
||||||
temp_config.delete
|
temp_config.delete
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_storage
|
def with_storage
|
||||||
with_default_config do |config, logger|
|
with_default_config do
|
||||||
temp_db = get_tempfile "mango-test-db"
|
temp_db = get_tempfile "mango-test-db"
|
||||||
storage = Storage.new temp_db.path, logger
|
storage = Storage.new temp_db.path, false
|
||||||
clear = yield storage, temp_db.path
|
clear = yield storage, temp_db.path
|
||||||
if clear == true
|
if clear == true
|
||||||
temp_db.delete
|
temp_db.delete
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def with_queue
|
|
||||||
with_default_config do |config, logger|
|
|
||||||
temp_queue_db = get_tempfile "mango-test-queue-db"
|
|
||||||
queue = MangaDex::Queue.new temp_queue_db.path, logger
|
|
||||||
clear = yield queue, temp_queue_db.path
|
|
||||||
if clear == true
|
|
||||||
temp_queue_db.delete
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
+77
-77
@@ -1,91 +1,91 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Storage do
|
describe Storage do
|
||||||
it "creates DB at given path" do
|
it "creates DB at given path" do
|
||||||
with_storage do |storage, path|
|
with_storage do |_, path|
|
||||||
File.exists?(path).should be_true
|
File.exists?(path).should be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "deletes user" do
|
it "deletes user" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
storage.delete_user "admin"
|
storage.delete_user "admin"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates new user" do
|
it "creates new user" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
storage.new_user "user", "123456", false
|
storage.new_user "user", "123456", false
|
||||||
storage.new_user "admin", "123456", true
|
storage.new_user "admin", "123456", true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "verifies username/password combination" do
|
it "verifies username/password combination" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
user_token = storage.verify_user "user", "123456"
|
user_token = storage.verify_user "user", "123456"
|
||||||
admin_token = storage.verify_user "admin", "123456"
|
admin_token = storage.verify_user "admin", "123456"
|
||||||
user_token.should_not be_nil
|
user_token.should_not be_nil
|
||||||
admin_token.should_not be_nil
|
admin_token.should_not be_nil
|
||||||
State.set "user_token", user_token
|
State.set "user_token", user_token
|
||||||
State.set "admin_token", admin_token
|
State.set "admin_token", admin_token
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "rejects duplicate username" do
|
it "rejects duplicate username" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
expect_raises SQLite3::Exception,
|
expect_raises SQLite3::Exception,
|
||||||
"UNIQUE constraint failed: users.username" do
|
"UNIQUE constraint failed: users.username" do
|
||||||
storage.new_user "admin", "123456", true
|
storage.new_user "admin", "123456", true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "verifies token" do
|
it "verifies token" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
user_token = State.get! "user_token"
|
user_token = State.get! "user_token"
|
||||||
user = storage.verify_token user_token
|
user = storage.verify_token user_token
|
||||||
user.should eq "user"
|
user.should eq "user"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "verfies admin token" do
|
it "verfies admin token" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
admin_token = State.get! "admin_token"
|
admin_token = State.get! "admin_token"
|
||||||
storage.verify_admin(admin_token).should be_true
|
storage.verify_admin(admin_token).should be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "rejects non-admin token" do
|
it "rejects non-admin token" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
user_token = State.get! "user_token"
|
user_token = State.get! "user_token"
|
||||||
storage.verify_admin(user_token).should be_false
|
storage.verify_admin(user_token).should be_false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "updates user" do
|
it "updates user" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
storage.update_user "admin", "admin", "654321", true
|
storage.update_user "admin", "admin", "654321", true
|
||||||
token = storage.verify_user "admin", "654321"
|
token = storage.verify_user "admin", "654321"
|
||||||
admin_token = State.get! "admin_token"
|
admin_token = State.get! "admin_token"
|
||||||
token.should eq admin_token
|
token.should eq admin_token
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "logs user out" do
|
it "logs user out" do
|
||||||
with_storage do |storage|
|
with_storage do |storage|
|
||||||
user_token = State.get! "user_token"
|
user_token = State.get! "user_token"
|
||||||
admin_token = State.get! "admin_token"
|
admin_token = State.get! "admin_token"
|
||||||
storage.logout user_token
|
storage.logout user_token
|
||||||
storage.logout admin_token
|
storage.logout admin_token
|
||||||
storage.verify_token(user_token).should be_nil
|
storage.verify_token(user_token).should be_nil
|
||||||
storage.verify_token(admin_token).should be_nil
|
storage.verify_token(admin_token).should be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "cleans up" do
|
it "cleans up" do
|
||||||
with_storage do
|
with_storage do
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
State.reset
|
State.reset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+40
-30
@@ -1,36 +1,46 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe "compare_alphanumerically" do
|
describe "compare_numerically" do
|
||||||
it "sorts filenames with leading zeros correctly" do
|
it "sorts filenames with leading zeros correctly" do
|
||||||
ary = ["010.jpg", "001.jpg", "002.png"]
|
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||||
ary.sort! {|a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||||
end
|
end
|
||||||
|
|
||||||
it "sorts filenames without leading zeros correctly" do
|
it "sorts filenames without leading zeros correctly" do
|
||||||
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
||||||
ary.sort! {|a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://ux.stackexchange.com/a/95441
|
# https://ux.stackexchange.com/a/95441
|
||||||
it "sorts like the stack exchange post" do
|
it "sorts like the stack exchange post" do
|
||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort {|a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://github.com/hkalexling/Mango/issues/22
|
# https://github.com/hkalexling/Mango/issues/22
|
||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort {|a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "chapter_sort" do
|
||||||
|
it "sorts correctly" do
|
||||||
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
|
sorter = ChapterSorter.new ary
|
||||||
|
ary.reverse.sort do |a, b|
|
||||||
|
sorter.compare a, b
|
||||||
|
end.should eq ary
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
require "compress/zip"
|
||||||
|
require "archive"
|
||||||
|
|
||||||
|
# A unified class to handle all supported archive formats. It uses the
|
||||||
|
# Compress::Zip module in crystal standard library if the target file is
|
||||||
|
# a zip archive. Otherwise it uses `archive.cr`.
|
||||||
|
class ArchiveFile
|
||||||
|
def initialize(@filename : String)
|
||||||
|
if [".cbz", ".zip"].includes? File.extname filename
|
||||||
|
@archive_file = Compress::Zip::File.new filename
|
||||||
|
else
|
||||||
|
@archive_file = Archive::File.new filename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.open(filename : String, &)
|
||||||
|
s = self.new filename
|
||||||
|
yield s
|
||||||
|
s.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
if @archive_file.is_a? Compress::Zip::File
|
||||||
|
@archive_file.as(Compress::Zip::File).close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Lists all file entries
|
||||||
|
def entries
|
||||||
|
ary = [] of Compress::Zip::File::Entry | Archive::Entry
|
||||||
|
@archive_file.entries.map do |e|
|
||||||
|
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
|
||||||
|
(e.is_a? Archive::Entry && e.info.file?)
|
||||||
|
ary.push e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
|
||||||
|
if e.is_a? Compress::Zip::File::Entry
|
||||||
|
data = nil
|
||||||
|
e.open do |io|
|
||||||
|
slice = Bytes.new e.uncompressed_size
|
||||||
|
bytes_read = io.read_fully? slice
|
||||||
|
data = slice if bytes_read
|
||||||
|
end
|
||||||
|
data
|
||||||
|
else
|
||||||
|
e.read
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check
|
||||||
|
if @archive_file.is_a? Archive::File
|
||||||
|
@archive_file.as(Archive::File).check
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
require "kemal"
|
|
||||||
require "./storage"
|
|
||||||
require "./util"
|
|
||||||
|
|
||||||
class AuthHandler < Kemal::Handler
|
|
||||||
def initialize(@storage : Storage)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call(env)
|
|
||||||
return call_next(env) \
|
|
||||||
if request_path_startswith env, ["/login", "/logout"]
|
|
||||||
|
|
||||||
cookie = env.request.cookies.find { |c| c.name == "token" }
|
|
||||||
if cookie.nil? || ! @storage.verify_token cookie.value
|
|
||||||
return env.redirect "/login"
|
|
||||||
end
|
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
|
||||||
unless @storage.verify_admin cookie.value
|
|
||||||
env.response.status_code = 403
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
call_next env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+88
-52
@@ -1,60 +1,96 @@
|
|||||||
require "yaml"
|
require "yaml"
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
property port : Int32 = 9000
|
@[YAML::Field(ignore: true)]
|
||||||
property library_path : String = \
|
property path : String = ""
|
||||||
File.expand_path "~/mango/library", home: true
|
property port : Int32 = 9000
|
||||||
property db_path : String = \
|
property base_url : String = "/"
|
||||||
File.expand_path "~/mango/mango.db", home: true
|
property session_secret : String = "mango-session-secret"
|
||||||
@[YAML::Field(key: "scan_interval_minutes")]
|
property library_path : String = File.expand_path "~/mango/library",
|
||||||
property scan_interval : Int32 = 5
|
home: true
|
||||||
property log_level : String = "info"
|
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||||
property mangadex = Hash(String, String|Int32).new
|
property scan_interval_minutes : Int32 = 5
|
||||||
|
property thumbnail_generation_interval_hours : Int32 = 24
|
||||||
|
property db_optimization_interval_hours : Int32 = 24
|
||||||
|
property log_level : String = "info"
|
||||||
|
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||||
|
home: true
|
||||||
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
|
home: true
|
||||||
|
property download_timeout_seconds : Int32 = 30
|
||||||
|
property page_margin : Int32 = 30
|
||||||
|
property disable_login = false
|
||||||
|
property default_username = ""
|
||||||
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
"base_url" => "https://mangadex.org",
|
"base_url" => "https://mangadex.org",
|
||||||
"api_url" => "https://mangadex.org/api",
|
"api_url" => "https://mangadex.org/api",
|
||||||
"download_wait_seconds" => 5,
|
"download_wait_seconds" => 5,
|
||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path "~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
home: true
|
home: true),
|
||||||
}
|
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
||||||
|
"manga_rename_rule" => "{title}",
|
||||||
|
}
|
||||||
|
|
||||||
def self.load(path : String?)
|
@@singlet : Config?
|
||||||
path = "~/.config/mango/config.yml" if path.nil?
|
|
||||||
cfg_path = File.expand_path path, home: true
|
|
||||||
if File.exists? cfg_path
|
|
||||||
config = self.from_yaml File.read cfg_path
|
|
||||||
config.fill_defaults
|
|
||||||
return config
|
|
||||||
end
|
|
||||||
puts "The config file #{cfg_path} does not exist." \
|
|
||||||
" Do you want mango to dump the default config there? [Y/n]"
|
|
||||||
input = gets
|
|
||||||
if input && input.downcase == "n"
|
|
||||||
abort "Aborting..."
|
|
||||||
end
|
|
||||||
default = self.allocate
|
|
||||||
default.fill_defaults
|
|
||||||
cfg_dir = File.dirname cfg_path
|
|
||||||
unless Dir.exists? cfg_dir
|
|
||||||
Dir.mkdir_p cfg_dir
|
|
||||||
end
|
|
||||||
File.write cfg_path, default.to_yaml
|
|
||||||
puts "The config file has been created at #{cfg_path}."
|
|
||||||
default
|
|
||||||
end
|
|
||||||
|
|
||||||
def fill_defaults
|
def self.current
|
||||||
{% for hash_name in ["mangadex"] %}
|
@@singlet.not_nil!
|
||||||
@{{hash_name.id}}_defaults.map do |k, v|
|
end
|
||||||
if @{{hash_name.id}}[k]?.nil?
|
|
||||||
@{{hash_name.id}}[k] = v
|
def set_current
|
||||||
end
|
@@singlet = self
|
||||||
end
|
end
|
||||||
{% end %}
|
|
||||||
end
|
def self.load(path : String?)
|
||||||
|
path = "~/.config/mango/config.yml" if path.nil?
|
||||||
|
cfg_path = File.expand_path path, home: true
|
||||||
|
if File.exists? cfg_path
|
||||||
|
config = self.from_yaml File.read cfg_path
|
||||||
|
config.preprocess
|
||||||
|
config.path = path
|
||||||
|
config.fill_defaults
|
||||||
|
return config
|
||||||
|
end
|
||||||
|
puts "The config file #{cfg_path} does not exist. " \
|
||||||
|
"Dumping the default config there."
|
||||||
|
default = self.allocate
|
||||||
|
default.path = path
|
||||||
|
default.fill_defaults
|
||||||
|
cfg_dir = File.dirname cfg_path
|
||||||
|
unless Dir.exists? cfg_dir
|
||||||
|
Dir.mkdir_p cfg_dir
|
||||||
|
end
|
||||||
|
File.write cfg_path, default.to_yaml
|
||||||
|
puts "The config file has been created at #{cfg_path}."
|
||||||
|
default
|
||||||
|
end
|
||||||
|
|
||||||
|
def fill_defaults
|
||||||
|
{% for hash_name in ["mangadex"] %}
|
||||||
|
@{{hash_name.id}}_defaults.map do |k, v|
|
||||||
|
if @{{hash_name.id}}[k]?.nil?
|
||||||
|
@{{hash_name.id}}[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
def preprocess
|
||||||
|
unless base_url.starts_with? "/"
|
||||||
|
raise "base url (#{base_url}) should start with `/`"
|
||||||
|
end
|
||||||
|
unless base_url.ends_with? "/"
|
||||||
|
@base_url += "/"
|
||||||
|
end
|
||||||
|
if disable_login && default_username.empty?
|
||||||
|
raise "Login is disabled, but default username is not set. " \
|
||||||
|
"Please set a default username"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
require "./config"
|
|
||||||
require "./library"
|
|
||||||
require "./storage"
|
|
||||||
require "./logger"
|
|
||||||
|
|
||||||
class Context
|
|
||||||
property config : Config
|
|
||||||
property library : Library
|
|
||||||
property storage : Storage
|
|
||||||
property logger : MLogger
|
|
||||||
property queue : MangaDex::Queue
|
|
||||||
|
|
||||||
def initialize(@config, @logger, @library, @storage, @queue)
|
|
||||||
end
|
|
||||||
|
|
||||||
{% for lvl in LEVELS %}
|
|
||||||
def {{lvl.id}}(msg)
|
|
||||||
@logger.{{lvl.id}} msg
|
|
||||||
end
|
|
||||||