Compare commits
104 Commits
389d71b42f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 | |||
| ee65c6263a | |||
| 691b6b22ab | |||
| 11c80a16a3 | |||
| c366b44c54 | |||
| 92f80542e6 | |||
| 3a25e42a20 | |||
| 24763bf5a7 | |||
| 08f0397029 | |||
| 766e3a01b2 | |||
| 626e2e035d | |||
| cfd2321db2 | |||
| 1b715033ce | |||
| 81d1586501 | |||
| bd74c9e3e3 | |||
| 41228430cf | |||
| 6a4ba06fac | |||
| e5c3542d3f | |||
| 24516f1069 | |||
| 5383cdef60 | |||
| be5c3f7a34 | |||
| caa9922ff9 | |||
| 135f000c71 | |||
| d9e50a4235 | |||
| 5f6eb5a5cb | |||
| 41c77fca2e | |||
| 49621f3fb1 | |||
| 6df743b2e6 | |||
| edfefc0128 | |||
| b0185abefe | |||
| b9e54cbfd8 | |||
| 3f0bd783cd | |||
| fc8856c83f | |||
| bd09f3d943 | |||
| 1f434c3d67 | |||
| 4972a403df | |||
| 629708cdd0 | |||
| 560087a897 | |||
| 27f553b005 | |||
| ed7665248e | |||
| 736b8aedc0 | |||
| 3daa49ae6c | |||
| 5fb24188e1 | |||
| 54f972db17 | |||
| acd8b62382 | |||
| cc65e3d1ad | |||
| 70889ca955 | |||
| 4ad6d57271 | |||
| fe5de3d5c1 | |||
| 5a224c48c0 | |||
| d08fe31b1b | |||
| 4d69ed91c5 | |||
| c6ddd3e6c7 | |||
| 504185f31f | |||
| acd0cce3f8 | |||
| e14da4fc8d | |||
| c04d4fb618 | |||
| 57bc82703d | |||
| e6aa7ebed0 | |||
| c44b51d6ef | |||
| d4c48de780 | |||
| 8948f75d62 | |||
| d304877a83 | |||
| 9cec32ba3e | |||
| e8768dfad7 | |||
| cfc98819ab | |||
| bfc1c76fe2 | |||
| 39e9f35acb | |||
| 36987f59b9 | |||
| 931d0e06f4 | |||
| 741a4da878 | |||
| e28b78d0e6 | |||
| 163dc3698c | |||
| 818bd82e0f | |||
| 76c8bcbf2c | |||
| 00094b22c6 | |||
| 1e4d9acebe | |||
| b226aa3a35 | |||
| d913be9d2a | |||
| e9bb951d97 | |||
| 037ede2750 | |||
| 06a245d90a | |||
| 63d5fcaa13 | |||
| 020cb6baae | |||
| 6db8042ffe | |||
| d4f87c4044 | |||
| 055c376222 | |||
| 1cc5d049ea | |||
| b955c2697c | |||
| 9a8c1577af | |||
| 52b9b0e00e | |||
| 51ef2fa725 | |||
| 7d53babc84 | |||
| 00f4445924 | |||
| 1a91c051b5 | |||
| 48ca9d0a8b | |||
| f75d795215 | |||
| ac13f53124 | |||
| c9ccf5cd90 | |||
| a99bfb5a91 |
17
.gitea/workflows/deploy.yml
Normal file
17
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # adapte la branche que tu veux déployer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||||
|
steps:
|
||||||
|
- name: Deploy stack
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
run: |
|
||||||
|
BUILDKIT_PROGRESS=plain cd /Users/julienfroidefond/Sites/docker-stack && docker pull julienfroidefond32/stripstream-backoffice && docker pull julienfroidefond32/stripstream-api && docker pull julienfroidefond32/stripstream-indexer && ./scripts/stack.sh up stripstream
|
||||||
328
Cargo.lock
generated
328
Cargo.lock
generated
@@ -19,6 +19,19 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -51,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.4.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -63,9 +76,12 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
"lru",
|
"lru",
|
||||||
|
"notifications",
|
||||||
"parsers",
|
"parsers",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -463,6 +479,29 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser-macros",
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"phf",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser-macros"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -483,6 +522,17 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "0.99.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -512,6 +562,21 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtoa-short"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||||
|
dependencies = [
|
||||||
|
"dtoa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecb"
|
name = "ecb"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -521,6 +586,12 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -629,6 +700,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||||
|
dependencies = [
|
||||||
|
"mac",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -728,6 +809,15 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -738,6 +828,15 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -855,6 +954,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever",
|
||||||
|
"match_token",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -1122,7 +1233,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.4.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1130,6 +1241,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
|
"notifications",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"parsers",
|
"parsers",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1406,6 +1518,37 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"string_cache",
|
||||||
|
"string_cache_codegen",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "match_token"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1496,6 +1639,12 @@ version = "1.0.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -1516,6 +1665,19 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notifications"
|
||||||
|
version = "1.27.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1624,7 +1786,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.4.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1690,6 +1852,58 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"rand 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1793,6 +2007,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -2065,6 +2285,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2073,6 +2294,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -2230,6 +2452,41 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scraper"
|
||||||
|
version = "0.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0e749d29b2064585327af5038a5a8eb73aeebad4a3472e83531a436563f7208"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"cssparser",
|
||||||
|
"ego-tree",
|
||||||
|
"getopts",
|
||||||
|
"html5ever",
|
||||||
|
"precomputed-hash",
|
||||||
|
"selectors",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "selectors"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cssparser",
|
||||||
|
"derive_more",
|
||||||
|
"fxhash",
|
||||||
|
"log",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"precomputed-hash",
|
||||||
|
"servo_arc",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -2302,6 +2559,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "servo_arc"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2365,6 +2631,12 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2613,6 +2885,31 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"parking_lot",
|
||||||
|
"phf_shared",
|
||||||
|
"precomputed-hash",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache_codegen"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2626,7 +2923,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.4.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2679,6 +2976,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tendril"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||||
|
dependencies = [
|
||||||
|
"futf",
|
||||||
|
"mac",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -2991,6 +3299,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -3038,6 +3352,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf16string"
|
name = "utf16string"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ members = [
|
|||||||
"apps/api",
|
"apps/api",
|
||||||
"apps/indexer",
|
"apps/indexer",
|
||||||
"crates/core",
|
"crates/core",
|
||||||
|
"crates/notifications",
|
||||||
"crates/parsers",
|
"crates/parsers",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.4.0"
|
version = "1.27.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
jpeg-decoder = "0.3"
|
jpeg-decoder = "0.3"
|
||||||
lru = "0.12"
|
lru = "0.12"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -41,3 +42,4 @@ walkdir = "2.5"
|
|||||||
webp = "0.3"
|
webp = "0.3"
|
||||||
utoipa = "4.0"
|
utoipa = "4.0"
|
||||||
utoipa-swagger-ui = "6.0"
|
utoipa-swagger-ui = "6.0"
|
||||||
|
scraper = "0.21"
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Julien Froidefond
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
78
README.md
78
README.md
@@ -81,28 +81,66 @@ The backoffice will be available at http://localhost:7082
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Libraries Management
|
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||||
- Create and manage multiple libraries
|
|
||||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
|
||||||
- Real-time file watcher for instant indexing
|
|
||||||
- Full and incremental rebuild options
|
|
||||||
|
|
||||||
### Books Management
|
### Libraries
|
||||||
- Support for CBZ, CBR, and PDF formats
|
- Multi-library management with per-library configuration
|
||||||
- Automatic metadata extraction
|
- Incremental and full scanning, real-time filesystem watcher
|
||||||
- Series and volume detection
|
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
|
||||||
- Full-text search powered by PostgreSQL
|
|
||||||
|
|
||||||
### Jobs Monitoring
|
### Books & Series
|
||||||
- Real-time job progress tracking
|
- **Formats**: CBZ, CBR, PDF, EPUB
|
||||||
- Detailed statistics (scanned, indexed, removed, errors)
|
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
|
||||||
- Job history and logs
|
- Series aggregation with missing volume detection
|
||||||
- Cancel pending jobs
|
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
|
||||||
|
- CBR → CBZ conversion
|
||||||
|
|
||||||
### Search
|
### Reading Progress
|
||||||
- Full-text search across titles, authors, and series
|
- Per-book tracking: unread / reading / read with current page
|
||||||
- Library filtering
|
- Series-level aggregated reading status
|
||||||
- Real-time suggestions
|
- Bulk mark-as-read for series
|
||||||
|
|
||||||
|
### Search & Discovery
|
||||||
|
- Full-text search across titles, authors, and series (PostgreSQL `pg_trgm`)
|
||||||
|
- Author listing with book/series counts
|
||||||
|
- Filtering by reading status, series status, format, metadata provider
|
||||||
|
|
||||||
|
### External Metadata
|
||||||
|
- Search, match, approve/reject workflow with confidence scoring
|
||||||
|
- Batch auto-matching and scheduled metadata refresh
|
||||||
|
- Field locking to protect manual edits from sync
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- **Telegram**: real-time notifications via Telegram Bot API
|
||||||
|
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
|
||||||
|
- Book thumbnail images included in notifications where applicable
|
||||||
|
- Test connection from settings
|
||||||
|
|
||||||
|
### External Integrations
|
||||||
|
- **Komga**: import reading progress
|
||||||
|
- **Prowlarr**: search for missing volumes
|
||||||
|
- **qBittorrent**: add torrents directly from search results
|
||||||
|
|
||||||
|
### Background Jobs
|
||||||
|
- Rebuild, rescan, thumbnail generation, metadata batch, CBR conversion
|
||||||
|
- Real-time progress via Server-Sent Events (SSE)
|
||||||
|
- Job history, error tracking, cancellation
|
||||||
|
|
||||||
|
### Page Rendering
|
||||||
|
- On-demand page extraction from all formats
|
||||||
|
- Image processing (format, quality, max width, resampling filter)
|
||||||
|
- LRU in-memory + disk cache
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Token-based auth (`admin` / `read` scopes) with Argon2 hashing
|
||||||
|
- Rate limiting, token expiration and revocation
|
||||||
|
|
||||||
|
### Web UI (Backoffice)
|
||||||
|
- Dashboard with statistics, interactive charts (recharts), and reading progress
|
||||||
|
- Currently reading & recently read sections
|
||||||
|
- Library, book, series, author management
|
||||||
|
- Live job monitoring, metadata search modals, settings panel
|
||||||
|
- Notification settings with per-event toggle configuration
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -249,4 +287,4 @@ volumes:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Your License Here]
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ futures = "0.3"
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
jpeg-decoder.workspace = true
|
jpeg-decoder.workspace = true
|
||||||
lru.workspace = true
|
lru.workspace = true
|
||||||
|
notifications = { path = "../../crates/notifications" }
|
||||||
stripstream-core = { path = "../../crates/core" }
|
stripstream-core = { path = "../../crates/core" }
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
regex = "1"
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
@@ -33,3 +35,4 @@ uuid.workspace = true
|
|||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
||||||
webp.workspace = true
|
webp.workspace = true
|
||||||
|
scraper.workspace = true
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
FROM rust:1-bookworm AS builder
|
FROM rust:1-bookworm AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install sccache for faster builds
|
# Copy workspace manifests and create dummy source files to cache dependency builds
|
||||||
RUN cargo install sccache --locked
|
|
||||||
ENV RUSTC_WRAPPER=sccache
|
|
||||||
ENV SCCACHE_DIR=/sccache
|
|
||||||
|
|
||||||
COPY Cargo.toml ./
|
COPY Cargo.toml ./
|
||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||||
|
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||||
|
|
||||||
|
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||||
|
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||||
|
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||||
|
echo "" > apps/indexer/src/lib.rs && \
|
||||||
|
echo "" > crates/core/src/lib.rs && \
|
||||||
|
echo "" > crates/notifications/src/lib.rs && \
|
||||||
|
echo "" > crates/parsers/src/lib.rs
|
||||||
|
|
||||||
|
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
cargo build --release -p api && \
|
||||||
|
cargo install sqlx-cli --no-default-features --features postgres --locked
|
||||||
|
|
||||||
|
# Copy real source code and build
|
||||||
COPY apps/api/src apps/api/src
|
COPY apps/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
|
COPY crates/notifications/src crates/notifications/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
# Build with sccache (cache persisted between builds via Docker cache mount)
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
RUN --mount=type=cache,target=/sccache \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||||
cargo build --release -p api && \
|
cargo build --release -p api && \
|
||||||
cargo install sqlx-cli --no-default-features --features postgres --locked
|
cp /app/target/release/api /usr/local/bin/api
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -42,7 +59,7 @@ RUN ARCH=$(dpkg --print-architecture) && \
|
|||||||
cp /tmp/lib/libpdfium.so /usr/local/lib/ && \
|
cp /tmp/lib/libpdfium.so /usr/local/lib/ && \
|
||||||
rm -rf /tmp/pdfium.tgz /tmp/lib /tmp/include && \
|
rm -rf /tmp/pdfium.tgz /tmp/lib /tmp/include && \
|
||||||
ldconfig
|
ldconfig
|
||||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
COPY --from=builder /usr/local/bin/api /usr/local/bin/api
|
||||||
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||||
COPY infra/migrations /app/migrations
|
COPY infra/migrations /app/migrations
|
||||||
COPY apps/api/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY apps/api/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
178
apps/api/src/authors.rs
Normal file
178
apps/api/src/authors.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use axum::{extract::{Query, State}, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ListAuthorsQuery {
|
||||||
|
#[schema(value_type = Option<String>, example = "batman")]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
|
pub page: Option<i64>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 20)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
/// Sort order: "name" (default), "books" (most books first)
|
||||||
|
#[schema(value_type = Option<String>, example = "books")]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct AuthorItem {
|
||||||
|
pub name: String,
|
||||||
|
pub book_count: i64,
|
||||||
|
pub series_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct AuthorsPageResponse {
|
||||||
|
pub items: Vec<AuthorItem>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: i64,
|
||||||
|
pub limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all unique authors with book/series counts
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/authors",
|
||||||
|
tag = "authors",
|
||||||
|
params(
|
||||||
|
("q" = Option<String>, Query, description = "Search by author name"),
|
||||||
|
("page" = Option<i64>, Query, description = "Page number (1-based)"),
|
||||||
|
("limit" = Option<i64>, Query, description = "Items per page (max 100)"),
|
||||||
|
("sort" = Option<String>, Query, description = "Sort: name (default) or books"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = AuthorsPageResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn list_authors(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListAuthorsQuery>,
|
||||||
|
) -> Result<Json<AuthorsPageResponse>, ApiError> {
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
let sort = query.sort.as_deref().unwrap_or("name");
|
||||||
|
|
||||||
|
let order_clause = match sort {
|
||||||
|
"books" => "book_count DESC, name ASC",
|
||||||
|
_ => "name ASC",
|
||||||
|
};
|
||||||
|
|
||||||
|
let q_pattern = query.q.as_deref()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| format!("%{s}%"));
|
||||||
|
|
||||||
|
// Aggregate unique authors from books.authors + books.author + series_metadata.authors
|
||||||
|
let sql = format!(
|
||||||
|
r#"
|
||||||
|
WITH all_authors AS (
|
||||||
|
SELECT DISTINCT UNNEST(
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(authors, '{{}}'),
|
||||||
|
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
||||||
|
)
|
||||||
|
) AS name
|
||||||
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{{}}'
|
||||||
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT name FROM all_authors
|
||||||
|
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||||
|
),
|
||||||
|
book_counts AS (
|
||||||
|
SELECT
|
||||||
|
f.name AS author_name,
|
||||||
|
COUNT(DISTINCT b.id) AS book_count
|
||||||
|
FROM filtered f
|
||||||
|
LEFT JOIN books b ON (
|
||||||
|
f.name = ANY(
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(b.authors, '{{}}'),
|
||||||
|
CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
GROUP BY f.name
|
||||||
|
),
|
||||||
|
series_counts AS (
|
||||||
|
SELECT
|
||||||
|
f.name AS author_name,
|
||||||
|
COUNT(DISTINCT (sm.library_id, sm.name)) AS series_count
|
||||||
|
FROM filtered f
|
||||||
|
LEFT JOIN series_metadata sm ON (
|
||||||
|
f.name = ANY(sm.authors) AND sm.authors != '{{}}'
|
||||||
|
)
|
||||||
|
GROUP BY f.name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.name,
|
||||||
|
COALESCE(bc.book_count, 0) AS book_count,
|
||||||
|
COALESCE(sc.series_count, 0) AS series_count
|
||||||
|
FROM filtered f
|
||||||
|
LEFT JOIN book_counts bc ON bc.author_name = f.name
|
||||||
|
LEFT JOIN series_counts sc ON sc.author_name = f.name
|
||||||
|
ORDER BY {order_clause}
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let count_sql = r#"
|
||||||
|
WITH all_authors AS (
|
||||||
|
SELECT DISTINCT UNNEST(
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(authors, '{}'),
|
||||||
|
CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END
|
||||||
|
)
|
||||||
|
) AS name
|
||||||
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{}'
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM all_authors
|
||||||
|
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (rows, count_row) = tokio::join!(
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(q_pattern.as_deref())
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.pool),
|
||||||
|
sqlx::query(count_sql)
|
||||||
|
.bind(q_pattern.as_deref())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = rows.map_err(|e| ApiError::internal(format!("authors query failed: {e}")))?;
|
||||||
|
let total: i64 = count_row
|
||||||
|
.map_err(|e| ApiError::internal(format!("authors count failed: {e}")))?
|
||||||
|
.get("total");
|
||||||
|
|
||||||
|
let items: Vec<AuthorItem> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| AuthorItem {
|
||||||
|
name: r.get("name"),
|
||||||
|
book_count: r.get("book_count"),
|
||||||
|
series_count: r.get("series_count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(AuthorsPageResponse {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ pub struct ListBooksQuery {
|
|||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||||
pub reading_status: Option<String>,
|
pub reading_status: Option<String>,
|
||||||
|
/// Filter by exact author name (matches in authors array or scalar author field)
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub author: Option<String>,
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -26,6 +29,9 @@ pub struct ListBooksQuery {
|
|||||||
/// Sort order: "title" (default) or "latest" (most recently added first)
|
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||||
#[schema(value_type = Option<String>, example = "latest")]
|
#[schema(value_type = Option<String>, example = "latest")]
|
||||||
pub sort: Option<String>,
|
pub sort: Option<String>,
|
||||||
|
/// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name
|
||||||
|
#[schema(value_type = Option<String>, example = "linked")]
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -84,6 +90,12 @@ pub struct BookDetails {
|
|||||||
pub reading_current_page: Option<i32>,
|
pub reading_current_page: Option<i32>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub reading_last_read_at: Option<DateTime<Utc>>,
|
pub reading_last_read_at: Option<DateTime<Utc>>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub isbn: Option<String>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
/// Fields locked from external metadata sync
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locked_fields: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List books with optional filtering and pagination
|
/// List books with optional filtering and pagination
|
||||||
@@ -93,12 +105,13 @@ pub struct BookDetails {
|
|||||||
tag = "books",
|
tag = "books",
|
||||||
params(
|
params(
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
|
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
|
||||||
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
|
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
|
||||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||||
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
||||||
|
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = BooksPage),
|
(status = 200, body = BooksPage),
|
||||||
@@ -129,15 +142,37 @@ pub async fn list_books(
|
|||||||
let rs_cond = if reading_statuses.is_some() {
|
let rs_cond = if reading_statuses.is_some() {
|
||||||
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
||||||
} else { String::new() };
|
} else { String::new() };
|
||||||
|
let author_cond = if query.author.is_some() {
|
||||||
|
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)) OR EXISTS (SELECT 1 FROM series_metadata sm WHERE sm.library_id = b.library_id AND sm.name = b.series AND ${p} = ANY(sm.authors)))")
|
||||||
|
} else { String::new() };
|
||||||
|
let metadata_cond = match query.metadata_provider.as_deref() {
|
||||||
|
Some("unlinked") => "AND eml.id IS NULL".to_string(),
|
||||||
|
Some("linked") => "AND eml.id IS NOT NULL".to_string(),
|
||||||
|
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata_links_cte = r#"
|
||||||
|
metadata_links AS (
|
||||||
|
SELECT DISTINCT ON (eml.series_name, eml.library_id)
|
||||||
|
eml.series_name, eml.library_id, eml.provider, eml.id
|
||||||
|
FROM external_metadata_links eml
|
||||||
|
WHERE eml.status = 'approved'
|
||||||
|
ORDER BY eml.series_name, eml.library_id, eml.created_at DESC
|
||||||
|
)"#;
|
||||||
|
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
r#"SELECT COUNT(*) FROM books b
|
r#"WITH {metadata_links_cte}
|
||||||
|
SELECT COUNT(*) FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
|
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}"#
|
{rs_cond}
|
||||||
|
{author_cond}
|
||||||
|
{metadata_cond}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||||
@@ -151,17 +186,21 @@ pub async fn list_books(
|
|||||||
let offset_p = p + 2;
|
let offset_p = p + 2;
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
|
WITH {metadata_links_cte}
|
||||||
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
||||||
COALESCE(brp.status, 'unread') AS reading_status,
|
COALESCE(brp.status, 'unread') AS reading_status,
|
||||||
brp.current_page AS reading_current_page,
|
brp.current_page AS reading_current_page,
|
||||||
brp.last_read_at AS reading_last_read_at
|
brp.last_read_at AS reading_last_read_at
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
|
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
|
{author_cond}
|
||||||
|
{metadata_cond}
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -186,6 +225,16 @@ pub async fn list_books(
|
|||||||
count_builder = count_builder.bind(statuses.clone());
|
count_builder = count_builder.bind(statuses.clone());
|
||||||
data_builder = data_builder.bind(statuses.clone());
|
data_builder = data_builder.bind(statuses.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(ref author) = query.author {
|
||||||
|
count_builder = count_builder.bind(author.clone());
|
||||||
|
data_builder = data_builder.bind(author.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref mp) = query.metadata_provider {
|
||||||
|
if mp != "linked" && mp != "unlinked" {
|
||||||
|
count_builder = count_builder.bind(mp.clone());
|
||||||
|
data_builder = data_builder.bind(mp.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
@@ -249,7 +298,7 @@ pub async fn get_book(
|
|||||||
) -> Result<Json<BookDetails>, ApiError> {
|
) -> Result<Json<BookDetails>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
|
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
|
||||||
bf.abs_path, bf.format, bf.parse_status,
|
bf.abs_path, bf.format, bf.parse_status,
|
||||||
COALESCE(brp.status, 'unread') AS reading_status,
|
COALESCE(brp.status, 'unread') AS reading_status,
|
||||||
brp.current_page AS reading_current_page,
|
brp.current_page AS reading_current_page,
|
||||||
@@ -290,560 +339,16 @@ pub async fn get_book(
|
|||||||
reading_status: row.get("reading_status"),
|
reading_status: row.get("reading_status"),
|
||||||
reading_current_page: row.get("reading_current_page"),
|
reading_current_page: row.get("reading_current_page"),
|
||||||
reading_last_read_at: row.get("reading_last_read_at"),
|
reading_last_read_at: row.get("reading_last_read_at"),
|
||||||
|
summary: row.get("summary"),
|
||||||
|
isbn: row.get("isbn"),
|
||||||
|
publish_date: row.get("publish_date"),
|
||||||
|
locked_fields: Some(row.get::<serde_json::Value, _>("locked_fields")),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
pub struct SeriesItem {
|
|
||||||
pub name: String,
|
|
||||||
pub book_count: i64,
|
|
||||||
pub books_read_count: i64,
|
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub first_book_id: Uuid,
|
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub library_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
pub(crate) fn remap_libraries_path(path: &str) -> String {
|
||||||
pub struct SeriesPage {
|
|
||||||
pub items: Vec<SeriesItem>,
|
|
||||||
pub total: i64,
|
|
||||||
pub page: i64,
|
|
||||||
pub limit: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct ListSeriesQuery {
|
|
||||||
#[schema(value_type = Option<String>, example = "dragon")]
|
|
||||||
pub q: Option<String>,
|
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
|
||||||
pub reading_status: Option<String>,
|
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
|
||||||
pub page: Option<i64>,
|
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all series in a library with pagination
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/libraries/{library_id}/series",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
|
||||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
|
||||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, body = SeriesPage),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn list_series(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(library_id): Path<Uuid>,
|
|
||||||
Query(query): Query<ListSeriesQuery>,
|
|
||||||
) -> Result<Json<SeriesPage>, ApiError> {
|
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
|
||||||
let offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
|
|
||||||
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
let series_status_expr = r#"CASE
|
|
||||||
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
|
||||||
WHEN sc.books_read_count = 0 THEN 'unread'
|
|
||||||
ELSE 'reading'
|
|
||||||
END"#;
|
|
||||||
|
|
||||||
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
|
|
||||||
let mut p: usize = 1;
|
|
||||||
|
|
||||||
let q_cond = if query.q.is_some() {
|
|
||||||
p += 1; format!("AND sc.name ILIKE ${p}")
|
|
||||||
} else { String::new() };
|
|
||||||
|
|
||||||
let count_rs_cond = if reading_statuses.is_some() {
|
|
||||||
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
|
||||||
} else { String::new() };
|
|
||||||
|
|
||||||
// q_cond et count_rs_cond partagent le même p — le count_sql les réutilise directement
|
|
||||||
let count_sql = format!(
|
|
||||||
r#"
|
|
||||||
WITH sorted_books AS (
|
|
||||||
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
|
|
||||||
FROM books WHERE library_id = $1
|
|
||||||
),
|
|
||||||
series_counts AS (
|
|
||||||
SELECT sb.name,
|
|
||||||
COUNT(*) as book_count,
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
|
||||||
FROM sorted_books sb
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
|
||||||
GROUP BY sb.name
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {count_rs_cond}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
// DATA: mêmes params dans le même ordre, puis limit/offset à la fin
|
|
||||||
let limit_p = p + 1;
|
|
||||||
let offset_p = p + 2;
|
|
||||||
|
|
||||||
let data_sql = format!(
|
|
||||||
r#"
|
|
||||||
WITH sorted_books AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
|
||||||
id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
|
||||||
ORDER BY
|
|
||||||
volume NULLS LAST,
|
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
|
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
|
||||||
title ASC
|
|
||||||
) as rn
|
|
||||||
FROM books
|
|
||||||
WHERE library_id = $1
|
|
||||||
),
|
|
||||||
series_counts AS (
|
|
||||||
SELECT
|
|
||||||
sb.name,
|
|
||||||
COUNT(*) as book_count,
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
|
||||||
FROM sorted_books sb
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
|
||||||
GROUP BY sb.name
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
sc.name,
|
|
||||||
sc.book_count,
|
|
||||||
sc.books_read_count,
|
|
||||||
sb.id as first_book_id
|
|
||||||
FROM series_counts sc
|
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
|
||||||
WHERE TRUE
|
|
||||||
{q_cond}
|
|
||||||
{count_rs_cond}
|
|
||||||
ORDER BY
|
|
||||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
|
|
||||||
COALESCE(
|
|
||||||
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
sc.name ASC
|
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
|
||||||
|
|
||||||
let mut count_builder = sqlx::query(&count_sql).bind(library_id);
|
|
||||||
let mut data_builder = sqlx::query(&data_sql).bind(library_id);
|
|
||||||
|
|
||||||
if let Some(ref pat) = q_pattern {
|
|
||||||
count_builder = count_builder.bind(pat);
|
|
||||||
data_builder = data_builder.bind(pat);
|
|
||||||
}
|
|
||||||
if let Some(ref statuses) = reading_statuses {
|
|
||||||
count_builder = count_builder.bind(statuses.clone());
|
|
||||||
data_builder = data_builder.bind(statuses.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
|
||||||
|
|
||||||
let (count_row, rows) = tokio::try_join!(
|
|
||||||
count_builder.fetch_one(&state.pool),
|
|
||||||
data_builder.fetch_all(&state.pool),
|
|
||||||
)?;
|
|
||||||
let total: i64 = count_row.get(0);
|
|
||||||
|
|
||||||
let mut items: Vec<SeriesItem> = rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| SeriesItem {
|
|
||||||
name: row.get("name"),
|
|
||||||
book_count: row.get("book_count"),
|
|
||||||
books_read_count: row.get("books_read_count"),
|
|
||||||
first_book_id: row.get("first_book_id"),
|
|
||||||
library_id,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(SeriesPage {
|
|
||||||
items: std::mem::take(&mut items),
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct ListAllSeriesQuery {
|
|
||||||
#[schema(value_type = Option<String>, example = "dragon")]
|
|
||||||
pub q: Option<String>,
|
|
||||||
#[schema(value_type = Option<String>)]
|
|
||||||
pub library_id: Option<Uuid>,
|
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
|
||||||
pub reading_status: Option<String>,
|
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
|
||||||
pub page: Option<i64>,
|
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
/// Sort order: "title" (default) or "latest" (most recently added first)
|
|
||||||
#[schema(value_type = Option<String>, example = "latest")]
|
|
||||||
pub sort: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all series across libraries with optional filtering and pagination
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/series",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
|
||||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
|
||||||
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, body = SeriesPage),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn list_all_series(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<ListAllSeriesQuery>,
|
|
||||||
) -> Result<Json<SeriesPage>, ApiError> {
|
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
|
||||||
let offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
|
|
||||||
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
let series_status_expr = r#"CASE
|
|
||||||
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
|
||||||
WHEN sc.books_read_count = 0 THEN 'unread'
|
|
||||||
ELSE 'reading'
|
|
||||||
END"#;
|
|
||||||
|
|
||||||
let mut p: usize = 0;
|
|
||||||
|
|
||||||
let lib_cond = if query.library_id.is_some() {
|
|
||||||
p += 1; format!("WHERE library_id = ${p}")
|
|
||||||
} else {
|
|
||||||
"WHERE TRUE".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let q_cond = if query.q.is_some() {
|
|
||||||
p += 1; format!("AND sc.name ILIKE ${p}")
|
|
||||||
} else { String::new() };
|
|
||||||
|
|
||||||
let rs_cond = if reading_statuses.is_some() {
|
|
||||||
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
|
||||||
} else { String::new() };
|
|
||||||
|
|
||||||
let count_sql = format!(
|
|
||||||
r#"
|
|
||||||
WITH sorted_books AS (
|
|
||||||
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
|
|
||||||
FROM books {lib_cond}
|
|
||||||
),
|
|
||||||
series_counts AS (
|
|
||||||
SELECT sb.name,
|
|
||||||
COUNT(*) as book_count,
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
|
||||||
FROM sorted_books sb
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
|
||||||
GROUP BY sb.name
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
let series_order_clause = if query.sort.as_deref() == Some("latest") {
|
|
||||||
"sc.latest_updated_at DESC".to_string()
|
|
||||||
} else {
|
|
||||||
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let limit_p = p + 1;
|
|
||||||
let offset_p = p + 2;
|
|
||||||
|
|
||||||
let data_sql = format!(
|
|
||||||
r#"
|
|
||||||
WITH sorted_books AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
|
||||||
id,
|
|
||||||
library_id,
|
|
||||||
updated_at,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
|
||||||
ORDER BY
|
|
||||||
volume NULLS LAST,
|
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
|
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
|
||||||
title ASC
|
|
||||||
) as rn
|
|
||||||
FROM books
|
|
||||||
{lib_cond}
|
|
||||||
),
|
|
||||||
series_counts AS (
|
|
||||||
SELECT
|
|
||||||
sb.name,
|
|
||||||
COUNT(*) as book_count,
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
|
||||||
MAX(sb.updated_at) as latest_updated_at
|
|
||||||
FROM sorted_books sb
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
|
||||||
GROUP BY sb.name
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
sc.name,
|
|
||||||
sc.book_count,
|
|
||||||
sc.books_read_count,
|
|
||||||
sb.id as first_book_id,
|
|
||||||
sb.library_id
|
|
||||||
FROM series_counts sc
|
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
|
||||||
WHERE TRUE
|
|
||||||
{q_cond}
|
|
||||||
{rs_cond}
|
|
||||||
ORDER BY {series_order_clause}
|
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
|
||||||
|
|
||||||
let mut count_builder = sqlx::query(&count_sql);
|
|
||||||
let mut data_builder = sqlx::query(&data_sql);
|
|
||||||
|
|
||||||
if let Some(lib_id) = query.library_id {
|
|
||||||
count_builder = count_builder.bind(lib_id);
|
|
||||||
data_builder = data_builder.bind(lib_id);
|
|
||||||
}
|
|
||||||
if let Some(ref pat) = q_pattern {
|
|
||||||
count_builder = count_builder.bind(pat);
|
|
||||||
data_builder = data_builder.bind(pat);
|
|
||||||
}
|
|
||||||
if let Some(ref statuses) = reading_statuses {
|
|
||||||
count_builder = count_builder.bind(statuses.clone());
|
|
||||||
data_builder = data_builder.bind(statuses.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
|
||||||
|
|
||||||
let (count_row, rows) = tokio::try_join!(
|
|
||||||
count_builder.fetch_one(&state.pool),
|
|
||||||
data_builder.fetch_all(&state.pool),
|
|
||||||
)?;
|
|
||||||
let total: i64 = count_row.get(0);
|
|
||||||
|
|
||||||
let items: Vec<SeriesItem> = rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| SeriesItem {
|
|
||||||
name: row.get("name"),
|
|
||||||
book_count: row.get("book_count"),
|
|
||||||
books_read_count: row.get("books_read_count"),
|
|
||||||
first_book_id: row.get("first_book_id"),
|
|
||||||
library_id: row.get("library_id"),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(SeriesPage {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct OngoingQuery {
|
|
||||||
#[schema(value_type = Option<i64>, example = 10)]
|
|
||||||
pub limit: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List ongoing series (partially read, sorted by most recent activity)
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/series/ongoing",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, body = Vec<SeriesItem>),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn ongoing_series(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<OngoingQuery>,
|
|
||||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
|
||||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
WITH series_stats AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
|
||||||
COUNT(*) AS book_count,
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
|
|
||||||
MAX(brp.last_read_at) AS last_read_at
|
|
||||||
FROM books b
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
|
||||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
|
||||||
HAVING (
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
|
||||||
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
first_books AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(series, ''), 'unclassified') AS name,
|
|
||||||
id,
|
|
||||||
library_id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
|
||||||
ORDER BY
|
|
||||||
volume NULLS LAST,
|
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
|
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
|
||||||
title ASC
|
|
||||||
) AS rn
|
|
||||||
FROM books
|
|
||||||
)
|
|
||||||
SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id, fb.library_id
|
|
||||||
FROM series_stats ss
|
|
||||||
JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1
|
|
||||||
ORDER BY ss.last_read_at DESC NULLS LAST
|
|
||||||
LIMIT $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(limit)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let items: Vec<SeriesItem> = rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| SeriesItem {
|
|
||||||
name: row.get("name"),
|
|
||||||
book_count: row.get("book_count"),
|
|
||||||
books_read_count: row.get("books_read_count"),
|
|
||||||
first_book_id: row.get("first_book_id"),
|
|
||||||
library_id: row.get("library_id"),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List next unread book for each ongoing series (sorted by most recent activity)
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/books/ongoing",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("limit" = Option<i64>, Query, description = "Max items to return (default 10, max 50)"),
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, body = Vec<BookItem>),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn ongoing_books(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<OngoingQuery>,
|
|
||||||
) -> Result<Json<Vec<BookItem>>, ApiError> {
|
|
||||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
WITH ongoing_series AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
|
||||||
MAX(brp.last_read_at) AS series_last_read_at
|
|
||||||
FROM books b
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
|
||||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
|
||||||
HAVING (
|
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
|
||||||
AND COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') < COUNT(*)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
next_books AS (
|
|
||||||
SELECT
|
|
||||||
b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume,
|
|
||||||
b.language, b.page_count, b.thumbnail_path, b.updated_at,
|
|
||||||
COALESCE(brp.status, 'unread') AS reading_status,
|
|
||||||
brp.current_page AS reading_current_page,
|
|
||||||
brp.last_read_at AS reading_last_read_at,
|
|
||||||
os.series_last_read_at,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
|
||||||
ORDER BY b.volume NULLS LAST, b.title
|
|
||||||
) AS rn
|
|
||||||
FROM books b
|
|
||||||
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
|
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
|
||||||
WHERE COALESCE(brp.status, 'unread') != 'read'
|
|
||||||
)
|
|
||||||
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
|
|
||||||
thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at
|
|
||||||
FROM next_books
|
|
||||||
WHERE rn = 1
|
|
||||||
ORDER BY series_last_read_at DESC NULLS LAST
|
|
||||||
LIMIT $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(limit)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let items: Vec<BookItem> = rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| {
|
|
||||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
|
||||||
BookItem {
|
|
||||||
id: row.get("id"),
|
|
||||||
library_id: row.get("library_id"),
|
|
||||||
kind: row.get("kind"),
|
|
||||||
format: row.get("format"),
|
|
||||||
title: row.get("title"),
|
|
||||||
author: row.get("author"),
|
|
||||||
authors: row.get::<Vec<String>, _>("authors"),
|
|
||||||
series: row.get("series"),
|
|
||||||
volume: row.get("volume"),
|
|
||||||
language: row.get("language"),
|
|
||||||
page_count: row.get("page_count"),
|
|
||||||
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))),
|
|
||||||
updated_at: row.get("updated_at"),
|
|
||||||
reading_status: row.get("reading_status"),
|
|
||||||
reading_current_page: row.get("reading_current_page"),
|
|
||||||
reading_last_read_at: row.get("reading_last_read_at"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remap_libraries_path(path: &str) -> String {
|
|
||||||
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
||||||
if path.starts_with("/libraries/") {
|
if path.starts_with("/libraries/") {
|
||||||
return path.replacen("/libraries", &root, 1);
|
return path.replacen("/libraries", &root, 1);
|
||||||
@@ -861,6 +366,8 @@ fn unmap_libraries_path(path: &str) -> String {
|
|||||||
path.to_string()
|
path.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Convert CBR → CBZ ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Enqueue a CBR → CBZ conversion job for a single book
|
/// Enqueue a CBR → CBZ conversion job for a single book
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -961,6 +468,12 @@ pub struct UpdateBookRequest {
|
|||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
pub volume: Option<i32>,
|
pub volume: Option<i32>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub isbn: Option<String>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
/// Fields locked from external metadata sync
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked_fields: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update metadata for a specific book
|
/// Update metadata for a specific book
|
||||||
@@ -996,12 +509,18 @@ pub async fn update_book(
|
|||||||
let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
|
|
||||||
|
let summary = body.summary.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
|
let isbn = body.isbn.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
|
let publish_date = body.publish_date.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
|
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE books
|
UPDATE books
|
||||||
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7, updated_at = NOW()
|
SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7,
|
||||||
|
summary = $8, isbn = $9, publish_date = $10, locked_fields = $11, updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
||||||
|
summary, isbn, publish_date,
|
||||||
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
|
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
|
||||||
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
|
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
|
||||||
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
||||||
@@ -1014,6 +533,10 @@ pub async fn update_book(
|
|||||||
.bind(&series)
|
.bind(&series)
|
||||||
.bind(body.volume)
|
.bind(body.volume)
|
||||||
.bind(&language)
|
.bind(&language)
|
||||||
|
.bind(&summary)
|
||||||
|
.bind(&isbn)
|
||||||
|
.bind(&publish_date)
|
||||||
|
.bind(&locked_fields)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1038,216 +561,14 @@ pub async fn update_book(
|
|||||||
reading_status: row.get("reading_status"),
|
reading_status: row.get("reading_status"),
|
||||||
reading_current_page: row.get("reading_current_page"),
|
reading_current_page: row.get("reading_current_page"),
|
||||||
reading_last_read_at: row.get("reading_last_read_at"),
|
reading_last_read_at: row.get("reading_last_read_at"),
|
||||||
|
summary: row.get("summary"),
|
||||||
|
isbn: row.get("isbn"),
|
||||||
|
publish_date: row.get("publish_date"),
|
||||||
|
locked_fields: Some(locked_fields),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
// ─── Thumbnail ────────────────────────────────────────────────────────────────
|
||||||
pub struct SeriesMetadata {
|
|
||||||
/// Authors of the series (series-level metadata, distinct from per-book author field)
|
|
||||||
pub authors: Vec<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub publishers: Vec<String>,
|
|
||||||
pub start_year: Option<i32>,
|
|
||||||
/// Convenience: author from first book (for pre-filling the per-book apply section)
|
|
||||||
pub book_author: Option<String>,
|
|
||||||
pub book_language: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get metadata for a specific series
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/libraries/{library_id}/series/{name}/metadata",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
|
||||||
("name" = String, Path, description = "Series name"),
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, body = SeriesMetadata),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn get_series_metadata(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path((library_id, name)): Path<(Uuid, String)>,
|
|
||||||
) -> Result<Json<SeriesMetadata>, ApiError> {
|
|
||||||
// author/language from first book of series
|
|
||||||
let books_row = if name == "unclassified" {
|
|
||||||
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND (series IS NULL OR series = '') LIMIT 1")
|
|
||||||
.bind(library_id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND series = $2 LIMIT 1")
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(&name)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta_row = sqlx::query(
|
|
||||||
"SELECT authors, description, publishers, start_year FROM series_metadata WHERE library_id = $1 AND name = $2"
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(&name)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(SeriesMetadata {
|
|
||||||
authors: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("authors")).unwrap_or_default(),
|
|
||||||
description: meta_row.as_ref().and_then(|r| r.get("description")),
|
|
||||||
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("publishers")).unwrap_or_default(),
|
|
||||||
start_year: meta_row.as_ref().and_then(|r| r.get("start_year")),
|
|
||||||
book_author: books_row.as_ref().and_then(|r| r.get("author")),
|
|
||||||
book_language: books_row.as_ref().and_then(|r| r.get("language")),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `author` and `language` are wrapped in an extra Option so we can distinguish
|
|
||||||
/// "absent from JSON" (keep books unchanged) from "present as null" (clear the field).
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct UpdateSeriesRequest {
|
|
||||||
pub new_name: String,
|
|
||||||
/// Series-level authors list (stored in series_metadata)
|
|
||||||
#[serde(default)]
|
|
||||||
pub authors: Vec<String>,
|
|
||||||
/// Per-book author propagation: absent = keep books unchanged, present = overwrite all books
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub author: Option<Option<String>>,
|
|
||||||
/// Per-book language propagation: absent = keep books unchanged, present = overwrite all books
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub language: Option<Option<String>>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub publishers: Vec<String>,
|
|
||||||
pub start_year: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub struct UpdateSeriesResponse {
|
|
||||||
pub updated: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update metadata for all books in a series
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/libraries/{library_id}/series/{name}",
|
|
||||||
tag = "books",
|
|
||||||
params(
|
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
|
||||||
("name" = String, Path, description = "Series name (use 'unclassified' for books without series)"),
|
|
||||||
),
|
|
||||||
request_body = UpdateSeriesRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, body = UpdateSeriesResponse),
|
|
||||||
(status = 400, description = "Invalid request"),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
|
||||||
security(("Bearer" = []))
|
|
||||||
)]
|
|
||||||
pub async fn update_series(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path((library_id, name)): Path<(Uuid, String)>,
|
|
||||||
Json(body): Json<UpdateSeriesRequest>,
|
|
||||||
) -> Result<Json<UpdateSeriesResponse>, ApiError> {
|
|
||||||
let new_name = body.new_name.trim().to_string();
|
|
||||||
if new_name.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("series name cannot be empty"));
|
|
||||||
}
|
|
||||||
// author/language: None = absent (keep books unchanged), Some(v) = apply to all books
|
|
||||||
let apply_author = body.author.is_some();
|
|
||||||
let author_value = body.author.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
|
||||||
let apply_language = body.language.is_some();
|
|
||||||
let language_value = body.language.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
|
||||||
let description = body.description.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
|
||||||
let publishers: Vec<String> = body.publishers.iter()
|
|
||||||
.map(|p| p.trim().to_string())
|
|
||||||
.filter(|p| !p.is_empty())
|
|
||||||
.collect();
|
|
||||||
let new_series_value: Option<String> = if new_name == "unclassified" { None } else { Some(new_name.clone()) };
|
|
||||||
|
|
||||||
// 1. Update books: always update series name; author/language only if opted-in
|
|
||||||
// $1=library_id, $2=new_series_value, $3=apply_author, $4=author_value,
|
|
||||||
// $5=apply_language, $6=language_value, [$7=old_name]
|
|
||||||
let result = if name == "unclassified" {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE books \
|
|
||||||
SET series = $2, \
|
|
||||||
author = CASE WHEN $3 THEN $4 ELSE author END, \
|
|
||||||
language = CASE WHEN $5 THEN $6 ELSE language END, \
|
|
||||||
updated_at = NOW() \
|
|
||||||
WHERE library_id = $1 AND (series IS NULL OR series = '')"
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(&new_series_value)
|
|
||||||
.bind(apply_author)
|
|
||||||
.bind(&author_value)
|
|
||||||
.bind(apply_language)
|
|
||||||
.bind(&language_value)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE books \
|
|
||||||
SET series = $2, \
|
|
||||||
author = CASE WHEN $3 THEN $4 ELSE author END, \
|
|
||||||
language = CASE WHEN $5 THEN $6 ELSE language END, \
|
|
||||||
updated_at = NOW() \
|
|
||||||
WHERE library_id = $1 AND series = $7"
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(&new_series_value)
|
|
||||||
.bind(apply_author)
|
|
||||||
.bind(&author_value)
|
|
||||||
.bind(apply_language)
|
|
||||||
.bind(&language_value)
|
|
||||||
.bind(&name)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Upsert series_metadata (keyed by new_name)
|
|
||||||
let meta_name = new_series_value.as_deref().unwrap_or("unclassified");
|
|
||||||
let authors: Vec<String> = body.authors.iter()
|
|
||||||
.map(|a| a.trim().to_string())
|
|
||||||
.filter(|a| !a.is_empty())
|
|
||||||
.collect();
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
|
||||||
ON CONFLICT (library_id, name) DO UPDATE
|
|
||||||
SET authors = EXCLUDED.authors,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
publishers = EXCLUDED.publishers,
|
|
||||||
start_year = EXCLUDED.start_year,
|
|
||||||
updated_at = NOW()
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(meta_name)
|
|
||||||
.bind(&authors)
|
|
||||||
.bind(&description)
|
|
||||||
.bind(&publishers)
|
|
||||||
.bind(body.start_year)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 3. If renamed, move series_metadata from old name to new name
|
|
||||||
if name != "unclassified" && new_name != name {
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
|
|
||||||
)
|
|
||||||
.bind(library_id)
|
|
||||||
.bind(&name)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(UpdateSeriesResponse { updated: result.rows_affected() }))
|
|
||||||
}
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -1310,12 +631,17 @@ pub async fn get_thumbnail(
|
|||||||
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::CACHE_CONTROL,
|
header::CACHE_CONTROL,
|
||||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
);
|
);
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&etag_value) {
|
||||||
|
headers.insert(header::ETAG, v);
|
||||||
|
}
|
||||||
|
|
||||||
Ok((StatusCode::OK, headers, Body::from(data)))
|
Ok((StatusCode::OK, headers, Body::from(data)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ pub struct RebuildRequest {
|
|||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
#[schema(value_type = Option<bool>, example = false)]
|
#[schema(value_type = Option<bool>, example = false)]
|
||||||
pub full: Option<bool>,
|
pub full: Option<bool>,
|
||||||
|
/// Deep rescan: clears directory mtimes to force re-walking all directories,
|
||||||
|
/// discovering newly supported formats without deleting existing data.
|
||||||
|
#[schema(value_type = Option<bool>, example = false)]
|
||||||
|
pub rescan: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
|
|||||||
) -> Result<Json<IndexJobResponse>, ApiError> {
|
) -> Result<Json<IndexJobResponse>, ApiError> {
|
||||||
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
||||||
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
|
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
|
||||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
let is_rescan = payload.as_ref().and_then(|p| p.0.rescan).unwrap_or(false);
|
||||||
|
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -154,10 +154,11 @@ pub async fn sync_komga_read_books(
|
|||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
type BookEntry = (Uuid, String, String);
|
||||||
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
|
// Primary: (series_lower, title_lower) -> Vec<(Uuid, title, series)>
|
||||||
let mut primary_map: HashMap<(String, String), Vec<(Uuid, String, String)>> = HashMap::new();
|
let mut primary_map: HashMap<(String, String), Vec<BookEntry>> = HashMap::new();
|
||||||
// Secondary: title_lower -> Vec<(Uuid, title, series)>
|
// Secondary: title_lower -> Vec<(Uuid, title, series)>
|
||||||
let mut secondary_map: HashMap<String, Vec<(Uuid, String, String)>> = HashMap::new();
|
let mut secondary_map: HashMap<String, Vec<BookEntry>> = HashMap::new();
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let id: Uuid = row.get("id");
|
let id: Uuid = row.get("id");
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ pub struct LibraryResponse {
|
|||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
pub watcher_enabled: bool,
|
pub watcher_enabled: bool,
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
|
pub fallback_metadata_provider: Option<String>,
|
||||||
|
pub metadata_refresh_mode: String,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub series_count: i64,
|
||||||
|
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
||||||
|
#[schema(value_type = Vec<String>)]
|
||||||
|
pub thumbnail_book_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -39,14 +48,27 @@ pub struct CreateLibraryRequest {
|
|||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<LibraryResponse>),
|
(status = 200, body = Vec<LibraryResponse>),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled,
|
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
|
||||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
|
||||||
|
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
|
||||||
|
COALESCE((
|
||||||
|
SELECT ARRAY_AGG(first_id ORDER BY series_name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
|
||||||
|
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
|
||||||
|
b.id as first_id
|
||||||
|
FROM books b
|
||||||
|
WHERE b.library_id = l.id
|
||||||
|
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
|
||||||
|
b.volume NULLS LAST, b.title ASC
|
||||||
|
LIMIT 5
|
||||||
|
) sub
|
||||||
|
), ARRAY[]::uuid[]) as thumbnail_book_ids
|
||||||
FROM libraries l ORDER BY l.created_at DESC"
|
FROM libraries l ORDER BY l.created_at DESC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -60,10 +82,16 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
|||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
|
series_count: row.get("series_count"),
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -111,10 +139,16 @@ pub async fn create_library(
|
|||||||
root_path,
|
root_path,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
book_count: 0,
|
book_count: 0,
|
||||||
|
series_count: 0,
|
||||||
monitor_enabled: false,
|
monitor_enabled: false,
|
||||||
scan_mode: "manual".to_string(),
|
scan_mode: "manual".to_string(),
|
||||||
next_scan_at: None,
|
next_scan_at: None,
|
||||||
watcher_enabled: false,
|
watcher_enabled: false,
|
||||||
|
metadata_provider: None,
|
||||||
|
fallback_metadata_provider: None,
|
||||||
|
metadata_refresh_mode: "manual".to_string(),
|
||||||
|
next_metadata_refresh_at: None,
|
||||||
|
thumbnail_book_ids: vec![],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
|
|||||||
(status = 200, body = IndexJobResponse),
|
(status = 200, body = IndexJobResponse),
|
||||||
(status = 404, description = "Library not found"),
|
(status = 404, description = "Library not found"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
@@ -206,7 +239,8 @@ pub async fn scan_library(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
|
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
|
||||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
let is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false);
|
||||||
|
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||||
|
|
||||||
// Create indexing job for this library
|
// Create indexing job for this library
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -235,6 +269,8 @@ pub struct UpdateMonitoringRequest {
|
|||||||
#[schema(value_type = String, example = "hourly")]
|
#[schema(value_type = String, example = "hourly")]
|
||||||
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
|
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
|
||||||
pub watcher_enabled: Option<bool>,
|
pub watcher_enabled: Option<bool>,
|
||||||
|
#[schema(value_type = Option<String>, example = "daily")]
|
||||||
|
pub metadata_refresh_mode: Option<String>, // 'manual', 'hourly', 'daily', 'weekly'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update monitoring settings for a library
|
/// Update monitoring settings for a library
|
||||||
@@ -265,6 +301,12 @@ pub async fn update_monitoring(
|
|||||||
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
|
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate metadata_refresh_mode
|
||||||
|
let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual");
|
||||||
|
if !valid_modes.contains(&metadata_refresh_mode) {
|
||||||
|
return Err(ApiError::bad_request("metadata_refresh_mode must be one of: manual, hourly, daily, weekly"));
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate next_scan_at if monitoring is enabled
|
// Calculate next_scan_at if monitoring is enabled
|
||||||
let next_scan_at = if input.monitor_enabled {
|
let next_scan_at = if input.monitor_enabled {
|
||||||
let interval_minutes = match input.scan_mode.as_str() {
|
let interval_minutes = match input.scan_mode.as_str() {
|
||||||
@@ -278,16 +320,31 @@ pub async fn update_monitoring(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate next_metadata_refresh_at
|
||||||
|
let next_metadata_refresh_at = if metadata_refresh_mode != "manual" {
|
||||||
|
let interval_minutes = match metadata_refresh_mode {
|
||||||
|
"hourly" => 60,
|
||||||
|
"daily" => 1440,
|
||||||
|
"weekly" => 10080,
|
||||||
|
_ => 1440,
|
||||||
|
};
|
||||||
|
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled"
|
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(input.monitor_enabled)
|
.bind(input.monitor_enabled)
|
||||||
.bind(input.scan_mode)
|
.bind(input.scan_mode)
|
||||||
.bind(next_scan_at)
|
.bind(next_scan_at)
|
||||||
.bind(watcher_enabled)
|
.bind(watcher_enabled)
|
||||||
|
.bind(metadata_refresh_mode)
|
||||||
|
.bind(next_metadata_refresh_at)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -300,15 +357,121 @@ pub async fn update_monitoring(
|
|||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT b.id FROM books b
|
||||||
|
WHERE b.library_id = $1
|
||||||
|
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||||
|
LIMIT 5"
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(Json(LibraryResponse {
|
Ok(Json(LibraryResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
root_path: row.get("root_path"),
|
root_path: row.get("root_path"),
|
||||||
enabled: row.get("enabled"),
|
enabled: row.get("enabled"),
|
||||||
book_count,
|
book_count,
|
||||||
|
series_count,
|
||||||
monitor_enabled: row.get("monitor_enabled"),
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
scan_mode: row.get("scan_mode"),
|
scan_mode: row.get("scan_mode"),
|
||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateMetadataProviderRequest {
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
|
pub fallback_metadata_provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the metadata provider for a library
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/libraries/{id}/metadata-provider",
|
||||||
|
tag = "libraries",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Library UUID"),
|
||||||
|
),
|
||||||
|
request_body = UpdateMetadataProviderRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = LibraryResponse),
|
||||||
|
(status = 404, description = "Library not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_metadata_provider(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(library_id): AxumPath<Uuid>,
|
||||||
|
Json(input): Json<UpdateMetadataProviderRequest>,
|
||||||
|
) -> Result<Json<LibraryResponse>, ApiError> {
|
||||||
|
let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||||
|
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(provider)
|
||||||
|
.bind(fallback)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(row) = result else {
|
||||||
|
return Err(ApiError::not_found("library not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let book_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM books WHERE library_id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT b.id FROM books b
|
||||||
|
WHERE b.library_id = $1
|
||||||
|
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||||
|
LIMIT 5"
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Json(LibraryResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
root_path: row.get("root_path"),
|
||||||
|
enabled: row.get("enabled"),
|
||||||
|
book_count,
|
||||||
|
series_count,
|
||||||
|
monitor_enabled: row.get("monitor_enabled"),
|
||||||
|
scan_mode: row.get("scan_mode"),
|
||||||
|
next_scan_at: row.get("next_scan_at"),
|
||||||
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
|
thumbnail_book_ids,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
|
mod authors;
|
||||||
mod books;
|
mod books;
|
||||||
mod error;
|
mod error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod index_jobs;
|
mod index_jobs;
|
||||||
mod komga;
|
mod komga;
|
||||||
mod libraries;
|
mod libraries;
|
||||||
|
mod metadata;
|
||||||
|
mod metadata_batch;
|
||||||
|
mod metadata_refresh;
|
||||||
|
mod metadata_providers;
|
||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
|
mod prowlarr;
|
||||||
|
mod qbittorrent;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod series;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod telegram;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
|
||||||
@@ -79,13 +88,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let admin_routes = Router::new()
|
let admin_routes = Router::new()
|
||||||
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
|
.route("/libraries", axum::routing::post(libraries::create_library))
|
||||||
.route("/libraries/:id", delete(libraries::delete_library))
|
.route("/libraries/:id", delete(libraries::delete_library))
|
||||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
|
||||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||||
|
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
|
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
||||||
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
||||||
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
||||||
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
||||||
@@ -99,9 +108,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
|
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||||
|
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||||
|
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||||
|
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
||||||
|
.route("/telegram/test", get(telegram::test_telegram))
|
||||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
|
.route("/metadata/search", axum::routing::post(metadata::search_metadata))
|
||||||
|
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
|
||||||
|
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
|
||||||
|
.route("/metadata/reject/:id", axum::routing::post(metadata::reject_metadata))
|
||||||
|
.route("/metadata/links", get(metadata::get_metadata_links))
|
||||||
|
.route("/metadata/missing/:id", get(metadata::get_missing_books))
|
||||||
|
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
|
||||||
|
.route("/metadata/batch", axum::routing::post(metadata_batch::start_batch))
|
||||||
|
.route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report))
|
||||||
|
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
|
||||||
|
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
|
||||||
|
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
|
||||||
.merge(settings::settings_routes())
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
@@ -109,17 +135,22 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let read_routes = Router::new()
|
let read_routes = Router::new()
|
||||||
|
.route("/libraries", get(libraries::list_libraries))
|
||||||
|
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||||
.route("/books", get(books::list_books))
|
.route("/books", get(books::list_books))
|
||||||
.route("/books/ongoing", get(books::ongoing_books))
|
.route("/books/ongoing", get(series::ongoing_books))
|
||||||
.route("/books/:id", get(books::get_book))
|
.route("/books/:id", get(books::get_book))
|
||||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||||
.route("/libraries/:library_id/series", get(books::list_series))
|
.route("/libraries/:library_id/series", get(series::list_series))
|
||||||
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
|
||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(series::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(series::ongoing_series))
|
||||||
|
.route("/series/statuses", get(series::series_statuses))
|
||||||
|
.route("/series/provider-statuses", get(series::provider_statuses))
|
||||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
|
.route("/authors", get(authors::list_authors))
|
||||||
.route("/stats", get(stats::get_stats))
|
.route("/stats", get(stats::get_stats))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||||
|
|||||||
1097
apps/api/src/metadata.rs
Normal file
1097
apps/api/src/metadata.rs
Normal file
File diff suppressed because it is too large
Load Diff
1145
apps/api/src/metadata_batch.rs
Normal file
1145
apps/api/src/metadata_batch.rs
Normal file
File diff suppressed because it is too large
Load Diff
342
apps/api/src/metadata_providers/anilist.rs
Normal file
342
apps/api/src/metadata_providers/anilist.rs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||||
|
|
||||||
|
pub struct AniListProvider;
|
||||||
|
|
||||||
|
impl MetadataProvider for AniListProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"anilist"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let query = query.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let external_id = external_id.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_QUERY: &str = r#"
|
||||||
|
query ($search: String) {
|
||||||
|
Page(perPage: 20) {
|
||||||
|
media(search: $search, type: MANGA, sort: SEARCH_MATCH) {
|
||||||
|
id
|
||||||
|
title { romaji english native }
|
||||||
|
description(asHtml: false)
|
||||||
|
coverImage { large medium }
|
||||||
|
startDate { year }
|
||||||
|
status
|
||||||
|
volumes
|
||||||
|
chapters
|
||||||
|
staff { edges { node { name { full } } role } }
|
||||||
|
siteUrl
|
||||||
|
genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const DETAIL_QUERY: &str = r#"
|
||||||
|
query ($id: Int) {
|
||||||
|
Media(id: $id, type: MANGA) {
|
||||||
|
id
|
||||||
|
title { romaji english native }
|
||||||
|
description(asHtml: false)
|
||||||
|
coverImage { large medium }
|
||||||
|
startDate { year }
|
||||||
|
status
|
||||||
|
volumes
|
||||||
|
chapters
|
||||||
|
staff { edges { node { name { full } } role } }
|
||||||
|
siteUrl
|
||||||
|
genres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
async fn graphql_request(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
query: &str,
|
||||||
|
variables: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let resp = client
|
||||||
|
.post("https://graphql.anilist.co")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"query": query,
|
||||||
|
"variables": variables,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("AniList request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("AniList returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse AniList response: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_series_impl(
|
||||||
|
query: &str,
|
||||||
|
_config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<SeriesCandidate>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let data = graphql_request(
|
||||||
|
&client,
|
||||||
|
SEARCH_QUERY,
|
||||||
|
serde_json::json!({ "search": query }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let media = match data
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("Page"))
|
||||||
|
.and_then(|p| p.get("media"))
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
{
|
||||||
|
Some(media) => media,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
|
let mut candidates: Vec<SeriesCandidate> = media
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| {
|
||||||
|
let id = m.get("id").and_then(|id| id.as_i64())?;
|
||||||
|
let title_obj = m.get("title")?;
|
||||||
|
let title = title_obj
|
||||||
|
.get("english")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let description = m
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|d| d.replace("\\n", "\n").trim().to_string())
|
||||||
|
.filter(|d| !d.is_empty());
|
||||||
|
|
||||||
|
let cover_url = m
|
||||||
|
.get("coverImage")
|
||||||
|
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let start_year = m
|
||||||
|
.get("startDate")
|
||||||
|
.and_then(|sd| sd.get("year"))
|
||||||
|
.and_then(|y| y.as_i64())
|
||||||
|
.map(|y| y as i32);
|
||||||
|
|
||||||
|
let volumes = m
|
||||||
|
.get("volumes")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
|
||||||
|
let chapters = m
|
||||||
|
.get("chapters")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
|
||||||
|
let status = m
|
||||||
|
.get("status")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("UNKNOWN")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let site_url = m
|
||||||
|
.get("siteUrl")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let authors = extract_authors(m);
|
||||||
|
|
||||||
|
let confidence = compute_confidence(&title, &query_lower);
|
||||||
|
|
||||||
|
// Use volumes if known, otherwise fall back to chapters count
|
||||||
|
let (total_volumes, volume_source) = match volumes {
|
||||||
|
Some(v) => (Some(v), "volumes"),
|
||||||
|
None => match chapters {
|
||||||
|
Some(c) => (Some(c), "chapters"),
|
||||||
|
None => (None, "unknown"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(SeriesCandidate {
|
||||||
|
external_id: id.to_string(),
|
||||||
|
title,
|
||||||
|
authors,
|
||||||
|
description,
|
||||||
|
publishers: vec![],
|
||||||
|
start_year,
|
||||||
|
total_volumes,
|
||||||
|
cover_url,
|
||||||
|
external_url: site_url,
|
||||||
|
confidence,
|
||||||
|
metadata_json: serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"chapters": chapters,
|
||||||
|
"volumes": volumes,
|
||||||
|
"volume_source": volume_source,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
candidates.truncate(10);
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_series_books_impl(
|
||||||
|
external_id: &str,
|
||||||
|
_config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<BookCandidate>, String> {
|
||||||
|
let id: i64 = external_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid AniList ID".to_string())?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let data = graphql_request(
|
||||||
|
&client,
|
||||||
|
DETAIL_QUERY,
|
||||||
|
serde_json::json!({ "id": id }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let media = match data.get("data").and_then(|d| d.get("Media")) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_obj = media.get("title").cloned().unwrap_or(serde_json::json!({}));
|
||||||
|
let title = title_obj
|
||||||
|
.get("english")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.or_else(|| title_obj.get("romaji").and_then(|t| t.as_str()))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let volumes = media
|
||||||
|
.get("volumes")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
|
||||||
|
let chapters = media
|
||||||
|
.get("chapters")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|v| v as i32);
|
||||||
|
|
||||||
|
// Use volumes if known, otherwise fall back to chapters count
|
||||||
|
let total = volumes.or(chapters);
|
||||||
|
|
||||||
|
let cover_url = media
|
||||||
|
.get("coverImage")
|
||||||
|
.and_then(|ci| ci.get("large").or_else(|| ci.get("medium")))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let description = media
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|d| d.replace("\\n", "\n").trim().to_string());
|
||||||
|
|
||||||
|
let authors = extract_authors(media);
|
||||||
|
|
||||||
|
// AniList doesn't have per-volume data — generate entries from volumes count (or chapters as fallback)
|
||||||
|
let mut books = Vec::new();
|
||||||
|
if let Some(total) = total {
|
||||||
|
for vol in 1..=total {
|
||||||
|
books.push(BookCandidate {
|
||||||
|
external_book_id: format!("{}-vol-{}", external_id, vol),
|
||||||
|
title: format!("{} Vol. {}", title, vol),
|
||||||
|
volume_number: Some(vol),
|
||||||
|
authors: authors.clone(),
|
||||||
|
isbn: None,
|
||||||
|
summary: if vol == 1 { description.clone() } else { None },
|
||||||
|
cover_url: if vol == 1 { cover_url.clone() } else { None },
|
||||||
|
page_count: None,
|
||||||
|
language: Some("ja".to_string()),
|
||||||
|
publish_date: None,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_authors(media: &serde_json::Value) -> Vec<String> {
|
||||||
|
let mut authors = Vec::new();
|
||||||
|
if let Some(edges) = media
|
||||||
|
.get("staff")
|
||||||
|
.and_then(|s| s.get("edges"))
|
||||||
|
.and_then(|e| e.as_array())
|
||||||
|
{
|
||||||
|
for edge in edges {
|
||||||
|
let role = edge
|
||||||
|
.get("role")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let role_lower = role.to_lowercase();
|
||||||
|
if role_lower.contains("story") || role_lower.contains("art") || role_lower.contains("original") {
|
||||||
|
if let Some(name) = edge
|
||||||
|
.get("node")
|
||||||
|
.and_then(|n| n.get("name"))
|
||||||
|
.and_then(|n| n.get("full"))
|
||||||
|
.and_then(|f| f.as_str())
|
||||||
|
{
|
||||||
|
if !authors.contains(&name.to_string()) {
|
||||||
|
authors.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
if title_lower == query {
|
||||||
|
1.0
|
||||||
|
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||||
|
0.8
|
||||||
|
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||||
|
0.7
|
||||||
|
} else {
|
||||||
|
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||||
|
let max_len = query.len().max(title_lower.len()).max(1);
|
||||||
|
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
671
apps/api/src/metadata_providers/bedetheque.rs
Normal file
671
apps/api/src/metadata_providers/bedetheque.rs
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||||
|
|
||||||
|
pub struct BedethequeProvider;
|
||||||
|
|
||||||
|
impl MetadataProvider for BedethequeProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"bedetheque"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let query = query.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let external_id = external_id.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client() -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(20))
|
||||||
|
.user_agent("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0")
|
||||||
|
.default_headers({
|
||||||
|
let mut h = reqwest::header::HeaderMap::new();
|
||||||
|
h.insert(
|
||||||
|
reqwest::header::ACCEPT,
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
h.insert(
|
||||||
|
reqwest::header::ACCEPT_LANGUAGE,
|
||||||
|
"fr-FR,fr;q=0.9,en;q=0.5".parse().unwrap(),
|
||||||
|
);
|
||||||
|
h.insert(reqwest::header::REFERER, "https://www.bedetheque.com/".parse().unwrap());
|
||||||
|
h
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove diacritics for URL construction (bedetheque uses ASCII slugs)
|
||||||
|
fn normalize_for_url(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => 'e',
|
||||||
|
'à' | 'â' | 'ä' | 'À' | 'Â' | 'Ä' => 'a',
|
||||||
|
'ù' | 'û' | 'ü' | 'Ù' | 'Û' | 'Ü' => 'u',
|
||||||
|
'ô' | 'ö' | 'Ô' | 'Ö' => 'o',
|
||||||
|
'î' | 'ï' | 'Î' | 'Ï' => 'i',
|
||||||
|
'ç' | 'Ç' => 'c',
|
||||||
|
'ñ' | 'Ñ' => 'n',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for byte in s.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
result.push(byte as char);
|
||||||
|
}
|
||||||
|
b' ' => result.push('+'),
|
||||||
|
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn search_series_impl(
|
||||||
|
query: &str,
|
||||||
|
_config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<SeriesCandidate>, String> {
|
||||||
|
let client = build_client()?;
|
||||||
|
|
||||||
|
// Use the full-text search page
|
||||||
|
let url = format!(
|
||||||
|
"https://www.bedetheque.com/search/tout?RechTexte={}&RechWhere=0",
|
||||||
|
urlencoded(&normalize_for_url(query))
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Bedetheque request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
return Err(format!("Bedetheque returned {status}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read Bedetheque response: {e}"))?;
|
||||||
|
|
||||||
|
// Detect IP blacklist
|
||||||
|
if html.contains("<title></title>") || html.contains("<title> </title>") {
|
||||||
|
return Err("Bedetheque: IP may be rate-limited, please retry later".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse HTML in a block so the non-Send Html type is dropped before any .await
|
||||||
|
let candidates = {
|
||||||
|
let document = Html::parse_document(&html);
|
||||||
|
let link_sel =
|
||||||
|
Selector::parse("a[href*='/serie-']").map_err(|e| format!("selector error: {e}"))?;
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
for el in document.select(&link_sel) {
|
||||||
|
let href = match el.value().attr("href") {
|
||||||
|
Some(h) => h.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (series_id, _slug) = match parse_serie_href(&href) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !seen.insert(series_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = el.text().collect::<String>().trim().to_string();
|
||||||
|
if title.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let confidence = compute_confidence(&title, &query_lower);
|
||||||
|
let cover_url = format!(
|
||||||
|
"https://www.bedetheque.com/cache/thb_series/PlancheS_{}.jpg",
|
||||||
|
series_id
|
||||||
|
);
|
||||||
|
|
||||||
|
candidates.push(SeriesCandidate {
|
||||||
|
external_id: series_id.clone(),
|
||||||
|
title: title.clone(),
|
||||||
|
authors: vec![],
|
||||||
|
description: None,
|
||||||
|
publishers: vec![],
|
||||||
|
start_year: None,
|
||||||
|
total_volumes: None,
|
||||||
|
cover_url: Some(cover_url),
|
||||||
|
external_url: Some(href),
|
||||||
|
confidence,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| {
|
||||||
|
b.confidence
|
||||||
|
.partial_cmp(&a.confidence)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
candidates.truncate(10);
|
||||||
|
candidates
|
||||||
|
}; // document is dropped here — safe to .await below
|
||||||
|
|
||||||
|
// For the top candidates, fetch series details to enrich metadata
|
||||||
|
// (limit to top 3 to avoid hammering the site)
|
||||||
|
let mut enriched = Vec::new();
|
||||||
|
for mut c in candidates {
|
||||||
|
if enriched.len() < 3 {
|
||||||
|
if let Ok(details) = fetch_series_details(&client, &c.external_id, c.external_url.as_deref()).await {
|
||||||
|
if let Some(desc) = details.description {
|
||||||
|
c.description = Some(desc);
|
||||||
|
}
|
||||||
|
if !details.authors.is_empty() {
|
||||||
|
c.authors = details.authors;
|
||||||
|
}
|
||||||
|
if !details.publishers.is_empty() {
|
||||||
|
c.publishers = details.publishers;
|
||||||
|
}
|
||||||
|
if let Some(year) = details.start_year {
|
||||||
|
c.start_year = Some(year);
|
||||||
|
}
|
||||||
|
if let Some(count) = details.album_count {
|
||||||
|
c.total_volumes = Some(count);
|
||||||
|
}
|
||||||
|
c.metadata_json = serde_json::json!({
|
||||||
|
"description": c.description,
|
||||||
|
"authors": c.authors,
|
||||||
|
"publishers": c.publishers,
|
||||||
|
"start_year": c.start_year,
|
||||||
|
"genres": details.genres,
|
||||||
|
"status": details.status,
|
||||||
|
"origin": details.origin,
|
||||||
|
"language": details.language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enriched.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(enriched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse serie URL to extract (id, slug)
|
||||||
|
fn parse_serie_href(href: &str) -> Option<(String, String)> {
|
||||||
|
// Patterns:
|
||||||
|
// https://www.bedetheque.com/serie-3-BD-Blacksad.html
|
||||||
|
// /serie-3-BD-Blacksad.html
|
||||||
|
let re = regex::Regex::new(r"/serie-(\d+)-[A-Za-z]+-(.+?)(?:__\d+)?\.html").ok()?;
|
||||||
|
let caps = re.captures(href)?;
|
||||||
|
Some((caps[1].to_string(), caps[2].to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeriesDetails {
|
||||||
|
description: Option<String>,
|
||||||
|
authors: Vec<String>,
|
||||||
|
publishers: Vec<String>,
|
||||||
|
start_year: Option<i32>,
|
||||||
|
album_count: Option<i32>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
origin: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_series_details(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
series_id: &str,
|
||||||
|
series_url: Option<&str>,
|
||||||
|
) -> Result<SeriesDetails, String> {
|
||||||
|
// Build URL — append __10000 to get all albums on one page
|
||||||
|
let url = match series_url {
|
||||||
|
Some(u) => {
|
||||||
|
// Replace .html with __10000.html
|
||||||
|
u.replace(".html", "__10000.html")
|
||||||
|
}
|
||||||
|
None => format!(
|
||||||
|
"https://www.bedetheque.com/serie-{}-BD-Serie__10000.html",
|
||||||
|
series_id
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch series page: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Series page returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read series page: {e}"))?;
|
||||||
|
|
||||||
|
let doc = Html::parse_document(&html);
|
||||||
|
let mut details = SeriesDetails {
|
||||||
|
description: None,
|
||||||
|
authors: vec![],
|
||||||
|
publishers: vec![],
|
||||||
|
start_year: None,
|
||||||
|
album_count: None,
|
||||||
|
genres: vec![],
|
||||||
|
status: None,
|
||||||
|
origin: None,
|
||||||
|
language: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Description from <meta name="description"> — format: "Tout sur la série {name} : {description}"
|
||||||
|
if let Ok(sel) = Selector::parse(r#"meta[name="description"]"#) {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
if let Some(content) = el.value().attr("content") {
|
||||||
|
let desc = content.trim().to_string();
|
||||||
|
// Strip the "Tout sur la série ... : " prefix
|
||||||
|
let cleaned = if let Some(pos) = desc.find(" : ") {
|
||||||
|
desc[pos + 3..].trim().to_string()
|
||||||
|
} else {
|
||||||
|
desc
|
||||||
|
};
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
details.description = Some(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract authors from itemprop="author" and itemprop="illustrator" (deduplicated)
|
||||||
|
{
|
||||||
|
let mut authors_set = std::collections::HashSet::new();
|
||||||
|
for attr in ["author", "illustrator"] {
|
||||||
|
if let Ok(sel) = Selector::parse(&format!(r#"[itemprop="{attr}"]"#)) {
|
||||||
|
for el in doc.select(&sel) {
|
||||||
|
let name = el.text().collect::<String>().trim().to_string();
|
||||||
|
// Names are "Last, First" — normalize to "First Last"
|
||||||
|
let normalized = if let Some((last, first)) = name.split_once(',') {
|
||||||
|
format!("{} {}", first.trim(), last.trim())
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
if !normalized.is_empty() && is_real_author(&normalized) {
|
||||||
|
authors_set.insert(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.authors = authors_set.into_iter().collect();
|
||||||
|
details.authors.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract publishers from itemprop="publisher" (deduplicated)
|
||||||
|
{
|
||||||
|
let mut publishers_set = std::collections::HashSet::new();
|
||||||
|
if let Ok(sel) = Selector::parse(r#"[itemprop="publisher"]"#) {
|
||||||
|
for el in doc.select(&sel) {
|
||||||
|
let name = el.text().collect::<String>().trim().to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
publishers_set.insert(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.publishers = publishers_set.into_iter().collect();
|
||||||
|
details.publishers.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract series-level info from <li><label>X :</label>value</li> blocks
|
||||||
|
// Genre: <li><label>Genre :</label><span class="style-serie">Animalier, Aventure, Humour</span></li>
|
||||||
|
if let Ok(sel) = Selector::parse("span.style-serie") {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
let text = el.text().collect::<String>();
|
||||||
|
details.genres = text
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parution: <li><label>Parution :</label><span class="parution-serie">Série finie</span></li>
|
||||||
|
if let Ok(sel) = Selector::parse("span.parution-serie") {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
let text = el.text().collect::<String>().trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
details.status = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origine and Langue from page text (no dedicated CSS class)
|
||||||
|
let page_text = doc.root_element().text().collect::<String>();
|
||||||
|
|
||||||
|
if let Some(val) = extract_info_value(&page_text, "Origine") {
|
||||||
|
let val = val.lines().next().unwrap_or(val).trim();
|
||||||
|
if !val.is_empty() {
|
||||||
|
details.origin = Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(val) = extract_info_value(&page_text, "Langue") {
|
||||||
|
let val = val.lines().next().unwrap_or(val).trim();
|
||||||
|
if !val.is_empty() {
|
||||||
|
details.language = Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album count from serie-info text (e.g. "Tomes : 8")
|
||||||
|
if let Ok(re) = regex::Regex::new(r"Tomes?\s*:\s*(\d+)") {
|
||||||
|
if let Some(caps) = re.captures(&page_text) {
|
||||||
|
if let Ok(n) = caps[1].parse::<i32>() {
|
||||||
|
details.album_count = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start year from first <meta itemprop="datePublished" content="YYYY-MM-DD">
|
||||||
|
if let Ok(sel) = Selector::parse(r#"[itemprop="datePublished"]"#) {
|
||||||
|
if let Some(el) = doc.select(&sel).next() {
|
||||||
|
if let Some(content) = el.value().attr("content") {
|
||||||
|
// content is "YYYY-MM-DD"
|
||||||
|
if let Some(year_str) = content.split('-').next() {
|
||||||
|
if let Ok(year) = year_str.parse::<i32>() {
|
||||||
|
details.start_year = Some(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract value after a label like "Scénario : Jean-Claude" → "Jean-Claude"
|
||||||
|
fn extract_info_value<'a>(text: &'a str, label: &str) -> Option<&'a str> {
|
||||||
|
// Handle both "Label :" and "Label:"
|
||||||
|
let patterns = [
|
||||||
|
format!("{} :", label),
|
||||||
|
format!("{}:", label),
|
||||||
|
format!("{} :", &label.to_lowercase()),
|
||||||
|
];
|
||||||
|
for pat in &patterns {
|
||||||
|
if let Some(pos) = text.find(pat.as_str()) {
|
||||||
|
let val = text[pos + pat.len()..].trim();
|
||||||
|
if !val.is_empty() {
|
||||||
|
return Some(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Get series books
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get_series_books_impl(
|
||||||
|
external_id: &str,
|
||||||
|
_config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<BookCandidate>, String> {
|
||||||
|
let client = build_client()?;
|
||||||
|
|
||||||
|
// We need to find the series URL — try a direct fetch
|
||||||
|
// external_id is the numeric series ID
|
||||||
|
// We try to fetch the series page to get the album list
|
||||||
|
let url = format!(
|
||||||
|
"https://www.bedetheque.com/serie-{}-BD-Serie__10000.html",
|
||||||
|
external_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch series: {e}"))?;
|
||||||
|
|
||||||
|
// If the generic slug fails, try without the slug part (bedetheque redirects)
|
||||||
|
let html = if resp.status().is_success() {
|
||||||
|
resp.text().await.map_err(|e| format!("Failed to read: {e}"))?
|
||||||
|
} else {
|
||||||
|
// Try alternative URL pattern
|
||||||
|
let alt_url = format!(
|
||||||
|
"https://www.bedetheque.com/serie-{}__10000.html",
|
||||||
|
external_id
|
||||||
|
);
|
||||||
|
let resp2 = client
|
||||||
|
.get(&alt_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch series (alt): {e}"))?;
|
||||||
|
if !resp2.status().is_success() {
|
||||||
|
return Err(format!("Series page not found for id {external_id}"));
|
||||||
|
}
|
||||||
|
resp2.text().await.map_err(|e| format!("Failed to read: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if html.contains("<title></title>") {
|
||||||
|
return Err("Bedetheque: IP may be rate-limited".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc = Html::parse_document(&html);
|
||||||
|
let mut books = Vec::new();
|
||||||
|
|
||||||
|
// Each album block starts before a .album-main div.
|
||||||
|
// The cover image (<img itemprop="image">) is OUTSIDE .album-main (sibling),
|
||||||
|
// so we iterate over a broader parent. But the simplest approach: parse all
|
||||||
|
// itemprop elements relative to each .album-main, plus pick covers separately.
|
||||||
|
let album_sel = Selector::parse(".album-main").map_err(|e| format!("selector: {e}"))?;
|
||||||
|
|
||||||
|
// Pre-collect cover images — they appear in <img itemprop="image"> before each .album-main
|
||||||
|
// and link to an album URL containing the book ID
|
||||||
|
let cover_sel = Selector::parse(r#"img[itemprop="image"]"#).map_err(|e| format!("selector: {e}"))?;
|
||||||
|
let covers: Vec<String> = doc.select(&cover_sel)
|
||||||
|
.filter_map(|el| el.value().attr("src").map(|s| {
|
||||||
|
if s.starts_with("http") { s.to_string() } else { format!("https://www.bedetheque.com{}", s) }
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
static RE_TOME: std::sync::LazyLock<regex::Regex> =
|
||||||
|
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)-Tome-\d+-").unwrap());
|
||||||
|
static RE_BOOK_ID: std::sync::LazyLock<regex::Regex> =
|
||||||
|
std::sync::LazyLock::new(|| regex::Regex::new(r"-(\d+)\.html").unwrap());
|
||||||
|
static RE_VOLUME: std::sync::LazyLock<regex::Regex> =
|
||||||
|
std::sync::LazyLock::new(|| regex::Regex::new(r"(?i)Tome-(\d+)-").unwrap());
|
||||||
|
|
||||||
|
for (idx, album_el) in doc.select(&album_sel).enumerate() {
|
||||||
|
// Title from <a class="titre" title="..."> — the title attribute is clean
|
||||||
|
let title_sel = Selector::parse("a.titre").ok();
|
||||||
|
let title_el = title_sel.as_ref().and_then(|s| album_el.select(s).next());
|
||||||
|
let title = title_el
|
||||||
|
.and_then(|el| el.value().attr("title"))
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if title.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External book ID from album URL (e.g. "...-1063.html")
|
||||||
|
let album_url = title_el.and_then(|el| el.value().attr("href")).unwrap_or("");
|
||||||
|
|
||||||
|
// Only keep main tomes — their URLs contain "Tome-{N}-"
|
||||||
|
// Skip hors-série (HS), intégrales (INT/INTFL), romans, coffrets, etc.
|
||||||
|
if !RE_TOME.is_match(album_url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let external_book_id = RE_BOOK_ID
|
||||||
|
.captures(album_url)
|
||||||
|
.map(|c| c[1].to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Volume number from URL pattern "Tome-{N}-" or from itemprop name
|
||||||
|
let volume_number = RE_VOLUME
|
||||||
|
.captures(album_url)
|
||||||
|
.and_then(|c| c[1].parse::<i32>().ok())
|
||||||
|
.or_else(|| extract_volume_from_title(&title));
|
||||||
|
|
||||||
|
// Authors from itemprop="author" and itemprop="illustrator"
|
||||||
|
let mut authors = Vec::new();
|
||||||
|
let author_sel = Selector::parse(r#"[itemprop="author"]"#).ok();
|
||||||
|
let illustrator_sel = Selector::parse(r#"[itemprop="illustrator"]"#).ok();
|
||||||
|
for sel in [&author_sel, &illustrator_sel].into_iter().flatten() {
|
||||||
|
for el in album_el.select(sel) {
|
||||||
|
let name = el.text().collect::<String>().trim().to_string();
|
||||||
|
// Names are "Last, First" format — normalize to "First Last"
|
||||||
|
let normalized = if let Some((last, first)) = name.split_once(',') {
|
||||||
|
format!("{} {}", first.trim(), last.trim())
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
if !normalized.is_empty() && is_real_author(&normalized) && !authors.contains(&normalized) {
|
||||||
|
authors.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISBN from <span itemprop="isbn">
|
||||||
|
let isbn = Selector::parse(r#"[itemprop="isbn"]"#)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_el.select(&s).next())
|
||||||
|
.map(|el| el.text().collect::<String>().trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
// Page count from <span itemprop="numberOfPages">
|
||||||
|
let page_count = Selector::parse(r#"[itemprop="numberOfPages"]"#)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_el.select(&s).next())
|
||||||
|
.and_then(|el| el.text().collect::<String>().trim().parse::<i32>().ok());
|
||||||
|
|
||||||
|
// Publish date from <meta itemprop="datePublished" content="YYYY-MM-DD">
|
||||||
|
let publish_date = Selector::parse(r#"[itemprop="datePublished"]"#)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| album_el.select(&s).next())
|
||||||
|
.and_then(|el| el.value().attr("content").map(|c| c.trim().to_string()))
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
// Cover from pre-collected covers (same index)
|
||||||
|
let cover_url = covers.get(idx).cloned();
|
||||||
|
|
||||||
|
books.push(BookCandidate {
|
||||||
|
external_book_id,
|
||||||
|
title,
|
||||||
|
volume_number,
|
||||||
|
authors,
|
||||||
|
isbn,
|
||||||
|
summary: None,
|
||||||
|
cover_url,
|
||||||
|
page_count,
|
||||||
|
language: Some("fr".to_string()),
|
||||||
|
publish_date,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter out placeholder author names from Bédéthèque
|
||||||
|
fn is_real_author(name: &str) -> bool {
|
||||||
|
!name.starts_with('<') && !name.ends_with('>') && name != "Collectif"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_volume_from_title(title: &str) -> Option<i32> {
|
||||||
|
let patterns = [
|
||||||
|
r"(?i)(?:tome|t\.)\s*(\d+)",
|
||||||
|
r"(?i)(?:vol(?:ume)?\.?)\s*(\d+)",
|
||||||
|
r"#\s*(\d+)",
|
||||||
|
];
|
||||||
|
for pattern in &patterns {
|
||||||
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
|
if let Some(caps) = re.captures(title) {
|
||||||
|
if let Ok(n) = caps[1].parse::<i32>() {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a title by removing French articles (leading or in parentheses)
|
||||||
|
/// and extra whitespace/punctuation, so that "Les Légendaires - Résistance"
|
||||||
|
/// and "Légendaires (Les) - Résistance" produce the same canonical form.
|
||||||
|
fn normalize_title(s: &str) -> String {
|
||||||
|
let lower = s.to_lowercase();
|
||||||
|
// Remove articles in parentheses: "(les)", "(la)", "(le)", "(l')", "(un)", "(une)", "(des)"
|
||||||
|
let re_parens = regex::Regex::new(r"\s*\((?:les?|la|l'|une?|des|du|d')\)").unwrap();
|
||||||
|
let cleaned = re_parens.replace_all(&lower, "");
|
||||||
|
// Remove leading articles: "les ", "la ", "le ", "l'", "un ", "une ", "des ", "du ", "d'"
|
||||||
|
let re_leading = regex::Regex::new(r"^(?:les?|la|l'|une?|des|du|d')\s+").unwrap();
|
||||||
|
let cleaned = re_leading.replace(&cleaned, "");
|
||||||
|
// Collapse multiple spaces/dashes into single
|
||||||
|
let re_spaces = regex::Regex::new(r"\s+").unwrap();
|
||||||
|
re_spaces.replace_all(cleaned.trim(), " ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
if title_lower == query_lower {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with normalized forms (handles Bedetheque's "Name (Article)" convention)
|
||||||
|
let title_norm = normalize_title(title);
|
||||||
|
let query_norm = normalize_title(query);
|
||||||
|
if title_norm == query_norm {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if title_lower.starts_with(&query_lower) || query_lower.starts_with(&title_lower)
|
||||||
|
|| title_norm.starts_with(&query_norm) || query_norm.starts_with(&title_norm)
|
||||||
|
{
|
||||||
|
0.85
|
||||||
|
} else if title_lower.contains(&query_lower) || query_lower.contains(&title_lower)
|
||||||
|
|| title_norm.contains(&query_norm) || query_norm.contains(&title_norm)
|
||||||
|
{
|
||||||
|
0.7
|
||||||
|
} else {
|
||||||
|
let common: usize = query_lower
|
||||||
|
.chars()
|
||||||
|
.filter(|c| title_lower.contains(*c))
|
||||||
|
.count();
|
||||||
|
let max_len = query_lower.len().max(title_lower.len()).max(1);
|
||||||
|
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
267
apps/api/src/metadata_providers/comicvine.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||||
|
|
||||||
|
pub struct ComicVineProvider;
|
||||||
|
|
||||||
|
impl MetadataProvider for ComicVineProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"comicvine"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let query = query.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let external_id = external_id.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client() -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.user_agent("StripstreamLibrarian/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_series_impl(
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<SeriesCandidate>, String> {
|
||||||
|
let api_key = config
|
||||||
|
.api_key
|
||||||
|
.as_deref()
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.ok_or_else(|| "ComicVine requires an API key. Configure it in Settings > Integrations.".to_string())?;
|
||||||
|
|
||||||
|
let client = build_client()?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://comicvine.gamespot.com/api/search/?api_key={}&format=json&resources=volume&query={}&limit=20",
|
||||||
|
api_key,
|
||||||
|
urlencoded(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("ComicVine request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("ComicVine returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
|
||||||
|
|
||||||
|
let results = match data.get("results").and_then(|r| r.as_array()) {
|
||||||
|
Some(results) => results,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
|
let mut candidates: Vec<SeriesCandidate> = results
|
||||||
|
.iter()
|
||||||
|
.filter_map(|vol| {
|
||||||
|
let name = vol.get("name").and_then(|n| n.as_str())?.to_string();
|
||||||
|
let id = vol.get("id").and_then(|id| id.as_i64())?;
|
||||||
|
let description = vol
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(strip_html);
|
||||||
|
let publisher = vol
|
||||||
|
.get("publisher")
|
||||||
|
.and_then(|p| p.get("name"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let start_year = vol
|
||||||
|
.get("start_year")
|
||||||
|
.and_then(|y| y.as_str())
|
||||||
|
.and_then(|y| y.parse::<i32>().ok());
|
||||||
|
let count_of_issues = vol
|
||||||
|
.get("count_of_issues")
|
||||||
|
.and_then(|c| c.as_i64())
|
||||||
|
.map(|c| c as i32);
|
||||||
|
let cover_url = vol
|
||||||
|
.get("image")
|
||||||
|
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let site_url = vol
|
||||||
|
.get("site_detail_url")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let confidence = compute_confidence(&name, &query_lower);
|
||||||
|
|
||||||
|
Some(SeriesCandidate {
|
||||||
|
external_id: id.to_string(),
|
||||||
|
title: name,
|
||||||
|
authors: vec![],
|
||||||
|
description,
|
||||||
|
publishers: publisher.into_iter().collect(),
|
||||||
|
start_year,
|
||||||
|
total_volumes: count_of_issues,
|
||||||
|
cover_url,
|
||||||
|
external_url: site_url,
|
||||||
|
confidence,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
candidates.truncate(10);
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_series_books_impl(
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<BookCandidate>, String> {
|
||||||
|
let api_key = config
|
||||||
|
.api_key
|
||||||
|
.as_deref()
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.ok_or_else(|| "ComicVine requires an API key".to_string())?;
|
||||||
|
|
||||||
|
let client = build_client()?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://comicvine.gamespot.com/api/issues/?api_key={}&format=json&filter=volume:{}&sort=issue_number:asc&limit=100&field_list=id,name,issue_number,description,image,cover_date,site_detail_url",
|
||||||
|
api_key,
|
||||||
|
external_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("ComicVine request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("ComicVine returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse ComicVine response: {e}"))?;
|
||||||
|
|
||||||
|
let results = match data.get("results").and_then(|r| r.as_array()) {
|
||||||
|
Some(results) => results,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let books: Vec<BookCandidate> = results
|
||||||
|
.iter()
|
||||||
|
.filter_map(|issue| {
|
||||||
|
let id = issue.get("id").and_then(|id| id.as_i64())?;
|
||||||
|
let name = issue
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let issue_number = issue
|
||||||
|
.get("issue_number")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.and_then(|n| n.parse::<f64>().ok())
|
||||||
|
.map(|n| n as i32);
|
||||||
|
let description = issue
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(strip_html);
|
||||||
|
let cover_url = issue
|
||||||
|
.get("image")
|
||||||
|
.and_then(|img| img.get("medium_url").or_else(|| img.get("small_url")))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let cover_date = issue
|
||||||
|
.get("cover_date")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
Some(BookCandidate {
|
||||||
|
external_book_id: id.to_string(),
|
||||||
|
title: name,
|
||||||
|
volume_number: issue_number,
|
||||||
|
authors: vec![],
|
||||||
|
isbn: None,
|
||||||
|
summary: description,
|
||||||
|
cover_url,
|
||||||
|
page_count: None,
|
||||||
|
language: None,
|
||||||
|
publish_date: cover_date,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_html(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut in_tag = false;
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
_ if !in_tag => result.push(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
if title_lower == query {
|
||||||
|
1.0
|
||||||
|
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||||
|
0.8
|
||||||
|
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||||
|
0.7
|
||||||
|
} else {
|
||||||
|
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||||
|
let max_len = query.len().max(title_lower.len()).max(1);
|
||||||
|
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for byte in s.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
result.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
472
apps/api/src/metadata_providers/google_books.rs
Normal file
472
apps/api/src/metadata_providers/google_books.rs
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||||
|
|
||||||
|
pub struct GoogleBooksProvider;
|
||||||
|
|
||||||
|
impl MetadataProvider for GoogleBooksProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"google_books"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let query = query.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let external_id = external_id.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_series_impl(
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<SeriesCandidate>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let search_query = format!("intitle:{}", query);
|
||||||
|
let mut url = format!(
|
||||||
|
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=20&printType=books&langRestrict={}",
|
||||||
|
urlencoded(&search_query),
|
||||||
|
urlencoded(&config.language),
|
||||||
|
);
|
||||||
|
if let Some(ref key) = config.api_key {
|
||||||
|
url.push_str(&format!("&key={}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Google Books request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Google Books returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse Google Books response: {e}"))?;
|
||||||
|
|
||||||
|
let items = match data.get("items").and_then(|i| i.as_array()) {
|
||||||
|
Some(items) => items,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group volumes by series name to produce series candidates
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let mut series_map: std::collections::HashMap<String, SeriesCandidateBuilder> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let volume_info = match item.get("volumeInfo") {
|
||||||
|
Some(vi) => vi,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = volume_info
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let authors: Vec<String> = volume_info
|
||||||
|
.get("authors")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let publisher = volume_info
|
||||||
|
.get("publisher")
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let published_date = volume_info
|
||||||
|
.get("publishedDate")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let description = volume_info
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
// Extract series info from title or seriesInfo
|
||||||
|
let series_name = volume_info
|
||||||
|
.get("seriesInfo")
|
||||||
|
.and_then(|si| si.get("title"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| extract_series_name(&title));
|
||||||
|
|
||||||
|
let cover_url = volume_info
|
||||||
|
.get("imageLinks")
|
||||||
|
.and_then(|il| {
|
||||||
|
il.get("thumbnail")
|
||||||
|
.or_else(|| il.get("smallThumbnail"))
|
||||||
|
})
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(|s| s.replace("http://", "https://"));
|
||||||
|
|
||||||
|
let google_id = item
|
||||||
|
.get("id")
|
||||||
|
.and_then(|id| id.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let entry = series_map
|
||||||
|
.entry(series_name.clone())
|
||||||
|
.or_insert_with(|| SeriesCandidateBuilder {
|
||||||
|
title: series_name.clone(),
|
||||||
|
authors: vec![],
|
||||||
|
description: None,
|
||||||
|
publishers: vec![],
|
||||||
|
start_year: None,
|
||||||
|
volume_count: 0,
|
||||||
|
cover_url: None,
|
||||||
|
external_id: google_id.clone(),
|
||||||
|
external_url: None,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.volume_count += 1;
|
||||||
|
|
||||||
|
// Merge authors
|
||||||
|
for a in &authors {
|
||||||
|
if !entry.authors.contains(a) {
|
||||||
|
entry.authors.push(a.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set description if not yet set
|
||||||
|
if entry.description.is_none() {
|
||||||
|
entry.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge publisher
|
||||||
|
if let Some(ref pub_name) = publisher {
|
||||||
|
if !entry.publishers.contains(pub_name) {
|
||||||
|
entry.publishers.push(pub_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract year
|
||||||
|
if let Some(ref date) = published_date {
|
||||||
|
if let Some(year) = extract_year(date) {
|
||||||
|
if entry.start_year.is_none() || entry.start_year.unwrap() > year {
|
||||||
|
entry.start_year = Some(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.cover_url.is_none() {
|
||||||
|
entry.cover_url = cover_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.external_url = Some(format!(
|
||||||
|
"https://books.google.com/books?id={}",
|
||||||
|
google_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<SeriesCandidate> = series_map
|
||||||
|
.into_values()
|
||||||
|
.map(|b| {
|
||||||
|
let confidence = compute_confidence(&b.title, &query_lower);
|
||||||
|
SeriesCandidate {
|
||||||
|
external_id: b.external_id,
|
||||||
|
title: b.title,
|
||||||
|
authors: b.authors,
|
||||||
|
description: b.description,
|
||||||
|
publishers: b.publishers,
|
||||||
|
start_year: b.start_year,
|
||||||
|
total_volumes: if b.volume_count > 1 {
|
||||||
|
Some(b.volume_count)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
cover_url: b.cover_url,
|
||||||
|
external_url: b.external_url,
|
||||||
|
confidence,
|
||||||
|
metadata_json: b.metadata_json,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
candidates.truncate(10);
|
||||||
|
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_series_books_impl(
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<BookCandidate>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
// First fetch the volume to get its series info
|
||||||
|
let mut url = format!(
|
||||||
|
"https://www.googleapis.com/books/v1/volumes/{}",
|
||||||
|
external_id
|
||||||
|
);
|
||||||
|
if let Some(ref key) = config.api_key {
|
||||||
|
url.push_str(&format!("?key={}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Google Books request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Google Books returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let volume: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse Google Books response: {e}"))?;
|
||||||
|
|
||||||
|
let volume_info = volume.get("volumeInfo").cloned().unwrap_or(serde_json::json!({}));
|
||||||
|
let title = volume_info
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Search for more volumes in this series
|
||||||
|
let series_name = extract_series_name(title);
|
||||||
|
let search_query = format!("intitle:{}", series_name);
|
||||||
|
let mut search_url = format!(
|
||||||
|
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=40&printType=books&langRestrict={}",
|
||||||
|
urlencoded(&search_query),
|
||||||
|
urlencoded(&config.language),
|
||||||
|
);
|
||||||
|
if let Some(ref key) = config.api_key {
|
||||||
|
search_url.push_str(&format!("&key={}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&search_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Google Books search failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
// Return just the single volume as a book
|
||||||
|
return Ok(vec![volume_to_book_candidate(&volume)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse search response: {e}"))?;
|
||||||
|
|
||||||
|
let items = match data.get("items").and_then(|i| i.as_array()) {
|
||||||
|
Some(items) => items,
|
||||||
|
None => return Ok(vec![volume_to_book_candidate(&volume)]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut books: Vec<BookCandidate> = items
|
||||||
|
.iter()
|
||||||
|
.map(volume_to_book_candidate)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by volume number
|
||||||
|
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||||
|
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume_to_book_candidate(item: &serde_json::Value) -> BookCandidate {
|
||||||
|
let volume_info = item.get("volumeInfo").cloned().unwrap_or(serde_json::json!({}));
|
||||||
|
let title = volume_info
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let authors: Vec<String> = volume_info
|
||||||
|
.get("authors")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let isbn = volume_info
|
||||||
|
.get("industryIdentifiers")
|
||||||
|
.and_then(|ids| ids.as_array())
|
||||||
|
.and_then(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.find(|id| {
|
||||||
|
id.get("type")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|t| t == "ISBN_13" || t == "ISBN_10")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.and_then(|id| id.get("identifier").and_then(|i| i.as_str()))
|
||||||
|
})
|
||||||
|
.map(String::from);
|
||||||
|
let summary = volume_info
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let cover_url = volume_info
|
||||||
|
.get("imageLinks")
|
||||||
|
.and_then(|il| il.get("thumbnail").or_else(|| il.get("smallThumbnail")))
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.map(|s| s.replace("http://", "https://"));
|
||||||
|
let page_count = volume_info
|
||||||
|
.get("pageCount")
|
||||||
|
.and_then(|p| p.as_i64())
|
||||||
|
.map(|p| p as i32);
|
||||||
|
let language = volume_info
|
||||||
|
.get("language")
|
||||||
|
.and_then(|l| l.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let publish_date = volume_info
|
||||||
|
.get("publishedDate")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let google_id = item
|
||||||
|
.get("id")
|
||||||
|
.and_then(|id| id.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let volume_number = extract_volume_number(&title);
|
||||||
|
|
||||||
|
BookCandidate {
|
||||||
|
external_book_id: google_id,
|
||||||
|
title,
|
||||||
|
volume_number,
|
||||||
|
authors,
|
||||||
|
isbn,
|
||||||
|
summary,
|
||||||
|
cover_url,
|
||||||
|
page_count,
|
||||||
|
language,
|
||||||
|
publish_date,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_series_name(title: &str) -> String {
|
||||||
|
// Remove trailing volume indicators like "Vol. 1", "Tome 2", "#3", "- Volume 1"
|
||||||
|
let re_patterns = [
|
||||||
|
r"(?i)\s*[-–—]\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||||
|
r"(?i)\s*,?\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||||
|
r"\s*\(\d+\)\s*$",
|
||||||
|
r"\s+\d+\s*$",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut result = title.to_string();
|
||||||
|
for pattern in &re_patterns {
|
||||||
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
|
let cleaned = re.replace(&result, "").to_string();
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
result = cleaned;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_volume_number(title: &str) -> Option<i32> {
|
||||||
|
let patterns = [
|
||||||
|
r"(?i)(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*(\d+)",
|
||||||
|
r"\((\d+)\)\s*$",
|
||||||
|
r"\b(\d+)\s*$",
|
||||||
|
];
|
||||||
|
|
||||||
|
for pattern in &patterns {
|
||||||
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
|
if let Some(caps) = re.captures(title) {
|
||||||
|
if let Some(num) = caps.get(1).and_then(|m| m.as_str().parse::<i32>().ok()) {
|
||||||
|
return Some(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_year(date: &str) -> Option<i32> {
|
||||||
|
date.get(..4).and_then(|s| s.parse::<i32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
if title_lower == query {
|
||||||
|
1.0
|
||||||
|
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||||
|
0.8
|
||||||
|
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||||
|
0.7
|
||||||
|
} else {
|
||||||
|
// Simple character overlap ratio
|
||||||
|
let common: usize = query
|
||||||
|
.chars()
|
||||||
|
.filter(|c| title_lower.contains(*c))
|
||||||
|
.count();
|
||||||
|
let max_len = query.len().max(title_lower.len()).max(1);
|
||||||
|
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for byte in s.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
result.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
result.push_str(&format!("%{:02X}", byte));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeriesCandidateBuilder {
|
||||||
|
title: String,
|
||||||
|
authors: Vec<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
publishers: Vec<String>,
|
||||||
|
start_year: Option<i32>,
|
||||||
|
volume_count: i32,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
external_id: String,
|
||||||
|
external_url: Option<String>,
|
||||||
|
metadata_json: serde_json::Value,
|
||||||
|
}
|
||||||
295
apps/api/src/metadata_providers/mod.rs
Normal file
295
apps/api/src/metadata_providers/mod.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
pub mod anilist;
|
||||||
|
pub mod bedetheque;
|
||||||
|
pub mod comicvine;
|
||||||
|
pub mod google_books;
|
||||||
|
pub mod open_library;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Configuration passed to providers (API keys, etc.)
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Preferred language for metadata results (ISO 639-1: "en", "fr", "es"). Defaults to "en".
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate series returned by a provider search
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SeriesCandidate {
|
||||||
|
pub external_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub publishers: Vec<String>,
|
||||||
|
pub start_year: Option<i32>,
|
||||||
|
pub total_volumes: Option<i32>,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
pub external_url: Option<String>,
|
||||||
|
pub confidence: f32,
|
||||||
|
pub metadata_json: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate book within a series
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BookCandidate {
|
||||||
|
pub external_book_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub volume_number: Option<i32>,
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
pub isbn: Option<String>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
pub page_count: Option<i32>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
pub metadata_json: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait that all metadata providers must implement
|
||||||
|
pub trait MetadataProvider: Send + Sync {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory function to get a provider by name
|
||||||
|
pub fn get_provider(name: &str) -> Option<Box<dyn MetadataProvider>> {
|
||||||
|
match name {
|
||||||
|
"google_books" => Some(Box::new(google_books::GoogleBooksProvider)),
|
||||||
|
"open_library" => Some(Box::new(open_library::OpenLibraryProvider)),
|
||||||
|
"comicvine" => Some(Box::new(comicvine::ComicVineProvider)),
|
||||||
|
"anilist" => Some(Box::new(anilist::AniListProvider)),
|
||||||
|
"bedetheque" => Some(Box::new(bedetheque::BedethequeProvider)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// End-to-end provider tests
|
||||||
|
//
|
||||||
|
// These tests hit real external APIs — run them explicitly with:
|
||||||
|
// cargo test -p api providers_e2e -- --ignored --nocapture
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod providers_e2e {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn config_fr() -> ProviderConfig {
|
||||||
|
ProviderConfig { api_key: None, language: "fr".to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_en() -> ProviderConfig {
|
||||||
|
ProviderConfig { api_key: None, language: "en".to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_candidate(name: &str, c: &SeriesCandidate) {
|
||||||
|
println!("\n=== {name} — best candidate ===");
|
||||||
|
println!(" title: {:?}", c.title);
|
||||||
|
println!(" external_id: {:?}", c.external_id);
|
||||||
|
println!(" authors: {:?}", c.authors);
|
||||||
|
println!(" description: {:?}", c.description.as_deref().map(|d| &d[..d.len().min(120)]));
|
||||||
|
println!(" publishers: {:?}", c.publishers);
|
||||||
|
println!(" start_year: {:?}", c.start_year);
|
||||||
|
println!(" total_volumes: {:?}", c.total_volumes);
|
||||||
|
println!(" cover_url: {}", c.cover_url.is_some());
|
||||||
|
println!(" external_url: {}", c.external_url.is_some());
|
||||||
|
println!(" confidence: {:.2}", c.confidence);
|
||||||
|
println!(" metadata_json: {}", serde_json::to_string_pretty(&c.metadata_json).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_books(name: &str, books: &[BookCandidate]) {
|
||||||
|
println!("\n=== {name} — {} books ===", books.len());
|
||||||
|
for (i, b) in books.iter().take(5).enumerate() {
|
||||||
|
println!(
|
||||||
|
" [{}] vol={:?} title={:?} authors={} isbn={:?} pages={:?} lang={:?} date={:?} cover={}",
|
||||||
|
i, b.volume_number, b.title, b.authors.len(), b.isbn, b.page_count, b.language, b.publish_date, b.cover_url.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if books.len() > 5 { println!(" ... and {} more", books.len() - 5); }
|
||||||
|
|
||||||
|
let with_vol = books.iter().filter(|b| b.volume_number.is_some()).count();
|
||||||
|
let with_isbn = books.iter().filter(|b| b.isbn.is_some()).count();
|
||||||
|
let with_authors = books.iter().filter(|b| !b.authors.is_empty()).count();
|
||||||
|
let with_date = books.iter().filter(|b| b.publish_date.is_some()).count();
|
||||||
|
let with_cover = books.iter().filter(|b| b.cover_url.is_some()).count();
|
||||||
|
let with_pages = books.iter().filter(|b| b.page_count.is_some()).count();
|
||||||
|
println!(" --- field coverage ---");
|
||||||
|
println!(" volume_number: {with_vol}/{}", books.len());
|
||||||
|
println!(" isbn: {with_isbn}/{}", books.len());
|
||||||
|
println!(" authors: {with_authors}/{}", books.len());
|
||||||
|
println!(" publish_date: {with_date}/{}", books.len());
|
||||||
|
println!(" cover_url: {with_cover}/{}", books.len());
|
||||||
|
println!(" page_count: {with_pages}/{}", books.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Google Books ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn google_books_search_and_books() {
|
||||||
|
let p = get_provider("google_books").unwrap();
|
||||||
|
let cfg = config_en();
|
||||||
|
|
||||||
|
let candidates = p.search_series("Blacksad", &cfg).await.unwrap();
|
||||||
|
assert!(!candidates.is_empty(), "google_books: no results for Blacksad");
|
||||||
|
print_candidate("google_books", &candidates[0]);
|
||||||
|
|
||||||
|
let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap();
|
||||||
|
print_books("google_books", &books);
|
||||||
|
assert!(!books.is_empty(), "google_books: no books returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Library ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn open_library_search_and_books() {
|
||||||
|
let p = get_provider("open_library").unwrap();
|
||||||
|
let cfg = config_en();
|
||||||
|
|
||||||
|
let candidates = p.search_series("Sandman Neil Gaiman", &cfg).await.unwrap();
|
||||||
|
assert!(!candidates.is_empty(), "open_library: no results for Sandman");
|
||||||
|
print_candidate("open_library", &candidates[0]);
|
||||||
|
|
||||||
|
let books = p.get_series_books(&candidates[0].external_id, &cfg).await.unwrap();
|
||||||
|
print_books("open_library", &books);
|
||||||
|
assert!(!books.is_empty(), "open_library: no books returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AniList ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn anilist_search_finished() {
|
||||||
|
let p = get_provider("anilist").unwrap();
|
||||||
|
let cfg = config_fr();
|
||||||
|
|
||||||
|
let candidates = p.search_series("Death Note", &cfg).await.unwrap();
|
||||||
|
assert!(!candidates.is_empty(), "anilist: no results for Death Note");
|
||||||
|
print_candidate("anilist (finished)", &candidates[0]);
|
||||||
|
|
||||||
|
let best = &candidates[0];
|
||||||
|
assert!(best.total_volumes.is_some(), "anilist: finished series should have total_volumes");
|
||||||
|
assert!(best.description.is_some(), "anilist: should have description");
|
||||||
|
assert!(!best.authors.is_empty(), "anilist: should have authors");
|
||||||
|
|
||||||
|
let status = best.metadata_json.get("status").and_then(|s| s.as_str());
|
||||||
|
assert_eq!(status, Some("FINISHED"), "anilist: Death Note should be FINISHED");
|
||||||
|
|
||||||
|
let books = p.get_series_books(&best.external_id, &cfg).await.unwrap();
|
||||||
|
print_books("anilist (Death Note)", &books);
|
||||||
|
assert!(books.len() >= 12, "anilist: Death Note should have ≥12 volumes, got {}", books.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn anilist_search_ongoing() {
|
||||||
|
let p = get_provider("anilist").unwrap();
|
||||||
|
let cfg = config_fr();
|
||||||
|
|
||||||
|
let candidates = p.search_series("One Piece", &cfg).await.unwrap();
|
||||||
|
assert!(!candidates.is_empty(), "anilist: no results for One Piece");
|
||||||
|
print_candidate("anilist (ongoing)", &candidates[0]);
|
||||||
|
|
||||||
|
let best = &candidates[0];
|
||||||
|
let status = best.metadata_json.get("status").and_then(|s| s.as_str());
|
||||||
|
assert_eq!(status, Some("RELEASING"), "anilist: One Piece should be RELEASING");
|
||||||
|
|
||||||
|
let volume_source = best.metadata_json.get("volume_source").and_then(|s| s.as_str());
|
||||||
|
println!(" volume_source: {:?}", volume_source);
|
||||||
|
println!(" total_volumes: {:?}", best.total_volumes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bédéthèque ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn bedetheque_search_and_books() {
|
||||||
|
let p = get_provider("bedetheque").unwrap();
|
||||||
|
let cfg = config_fr();
|
||||||
|
|
||||||
|
let candidates = p.search_series("De Cape et de Crocs", &cfg).await.unwrap();
|
||||||
|
assert!(!candidates.is_empty(), "bedetheque: no results");
|
||||||
|
print_candidate("bedetheque", &candidates[0]);
|
||||||
|
|
||||||
|
let best = &candidates[0];
|
||||||
|
assert!(best.description.is_some(), "bedetheque: should have description");
|
||||||
|
assert!(!best.authors.is_empty(), "bedetheque: should have authors");
|
||||||
|
assert!(!best.publishers.is_empty(), "bedetheque: should have publishers");
|
||||||
|
assert!(best.start_year.is_some(), "bedetheque: should have start_year");
|
||||||
|
assert!(best.total_volumes.is_some(), "bedetheque: should have total_volumes");
|
||||||
|
|
||||||
|
// Enriched metadata_json
|
||||||
|
let mj = &best.metadata_json;
|
||||||
|
assert!(mj.get("genres").and_then(|g| g.as_array()).map(|a| !a.is_empty()).unwrap_or(false), "bedetheque: should have genres");
|
||||||
|
assert!(mj.get("status").and_then(|s| s.as_str()).is_some(), "bedetheque: should have status");
|
||||||
|
|
||||||
|
let books = p.get_series_books(&best.external_id, &cfg).await.unwrap();
|
||||||
|
print_books("bedetheque", &books);
|
||||||
|
assert!(books.len() >= 12, "bedetheque: De Cape et de Crocs should have ≥12 volumes, got {}", books.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ComicVine (needs API key) ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn comicvine_no_key() {
|
||||||
|
let p = get_provider("comicvine").unwrap();
|
||||||
|
let cfg = config_en();
|
||||||
|
|
||||||
|
let result = p.search_series("Batman", &cfg).await;
|
||||||
|
println!("\n=== comicvine (no key) ===");
|
||||||
|
match result {
|
||||||
|
Ok(c) => println!(" returned {} candidates (unexpected without key)", c.len()),
|
||||||
|
Err(e) => println!(" expected error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cross-provider comparison ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn cross_provider_blacksad() {
|
||||||
|
println!("\n{}", "=".repeat(60));
|
||||||
|
println!(" Cross-provider comparison: Blacksad");
|
||||||
|
println!("{}\n", "=".repeat(60));
|
||||||
|
|
||||||
|
let providers: Vec<(&str, ProviderConfig)> = vec![
|
||||||
|
("google_books", config_en()),
|
||||||
|
("open_library", config_en()),
|
||||||
|
("anilist", config_fr()),
|
||||||
|
("bedetheque", config_fr()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, cfg) in &providers {
|
||||||
|
let p = get_provider(name).unwrap();
|
||||||
|
match p.search_series("Blacksad", cfg).await {
|
||||||
|
Ok(candidates) if !candidates.is_empty() => {
|
||||||
|
let b = &candidates[0];
|
||||||
|
println!("[{name}] title={:?} authors={} desc={} pubs={} year={:?} vols={:?} cover={} url={} conf={:.2}",
|
||||||
|
b.title, b.authors.len(), b.description.is_some(), b.publishers.len(),
|
||||||
|
b.start_year, b.total_volumes, b.cover_url.is_some(), b.external_url.is_some(), b.confidence);
|
||||||
|
}
|
||||||
|
Ok(_) => println!("[{name}] no results"),
|
||||||
|
Err(e) => println!("[{name}] error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
351
apps/api/src/metadata_providers/open_library.rs
Normal file
351
apps/api/src/metadata_providers/open_library.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
use super::{BookCandidate, MetadataProvider, ProviderConfig, SeriesCandidate};
|
||||||
|
|
||||||
|
pub struct OpenLibraryProvider;
|
||||||
|
|
||||||
|
impl MetadataProvider for OpenLibraryProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"open_library"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_series(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<SeriesCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let query = query.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { search_series_impl(&query, &config).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_series_books(
|
||||||
|
&self,
|
||||||
|
external_id: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<dyn std::future::Future<Output = Result<Vec<BookCandidate>, String>> + Send + '_>,
|
||||||
|
> {
|
||||||
|
let external_id = external_id.to_string();
|
||||||
|
let config = config.clone();
|
||||||
|
Box::pin(async move { get_series_books_impl(&external_id, &config).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_series_impl(
|
||||||
|
query: &str,
|
||||||
|
config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<SeriesCandidate>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
// Open Library uses 3-letter language codes
|
||||||
|
let ol_lang = match config.language.as_str() {
|
||||||
|
"fr" => "fre",
|
||||||
|
"es" => "spa",
|
||||||
|
_ => "eng",
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://openlibrary.org/search.json?title={}&limit=20&language={}",
|
||||||
|
urlencoded(query),
|
||||||
|
ol_lang,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Open Library request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Open Library returned {status}: {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse Open Library response: {e}"))?;
|
||||||
|
|
||||||
|
let docs = match data.get("docs").and_then(|d| d.as_array()) {
|
||||||
|
Some(docs) => docs,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let mut series_map: std::collections::HashMap<String, SeriesCandidateBuilder> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for doc in docs {
|
||||||
|
let title = doc
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let authors: Vec<String> = doc
|
||||||
|
.get("author_name")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let publishers: Vec<String> = doc
|
||||||
|
.get("publisher")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
let mut pubs: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
|
||||||
|
pubs.truncate(3);
|
||||||
|
pubs
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let first_publish_year = doc
|
||||||
|
.get("first_publish_year")
|
||||||
|
.and_then(|y| y.as_i64())
|
||||||
|
.map(|y| y as i32);
|
||||||
|
let cover_i = doc.get("cover_i").and_then(|c| c.as_i64());
|
||||||
|
let cover_url = cover_i.map(|id| format!("https://covers.openlibrary.org/b/id/{}-M.jpg", id));
|
||||||
|
let key = doc
|
||||||
|
.get("key")
|
||||||
|
.and_then(|k| k.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let series_name = extract_series_name(&title);
|
||||||
|
|
||||||
|
let entry = series_map
|
||||||
|
.entry(series_name.clone())
|
||||||
|
.or_insert_with(|| SeriesCandidateBuilder {
|
||||||
|
title: series_name.clone(),
|
||||||
|
authors: vec![],
|
||||||
|
description: None,
|
||||||
|
publishers: vec![],
|
||||||
|
start_year: None,
|
||||||
|
volume_count: 0,
|
||||||
|
cover_url: None,
|
||||||
|
external_id: key.clone(),
|
||||||
|
external_url: if key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("https://openlibrary.org{}", key))
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.volume_count += 1;
|
||||||
|
|
||||||
|
for a in &authors {
|
||||||
|
if !entry.authors.contains(a) {
|
||||||
|
entry.authors.push(a.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in &publishers {
|
||||||
|
if !entry.publishers.contains(p) {
|
||||||
|
entry.publishers.push(p.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.start_year.is_none() || first_publish_year.is_some_and(|y| entry.start_year.unwrap() > y))
|
||||||
|
&& first_publish_year.is_some()
|
||||||
|
{
|
||||||
|
entry.start_year = first_publish_year;
|
||||||
|
}
|
||||||
|
if entry.cover_url.is_none() {
|
||||||
|
entry.cover_url = cover_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<SeriesCandidate> = series_map
|
||||||
|
.into_values()
|
||||||
|
.map(|b| {
|
||||||
|
let confidence = compute_confidence(&b.title, &query_lower);
|
||||||
|
SeriesCandidate {
|
||||||
|
external_id: b.external_id,
|
||||||
|
title: b.title,
|
||||||
|
authors: b.authors,
|
||||||
|
description: b.description,
|
||||||
|
publishers: b.publishers,
|
||||||
|
start_year: b.start_year,
|
||||||
|
total_volumes: if b.volume_count > 1 { Some(b.volume_count) } else { None },
|
||||||
|
cover_url: b.cover_url,
|
||||||
|
external_url: b.external_url,
|
||||||
|
confidence,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
candidates.truncate(10);
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_series_books_impl(
|
||||||
|
external_id: &str,
|
||||||
|
_config: &ProviderConfig,
|
||||||
|
) -> Result<Vec<BookCandidate>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
// Fetch the work to get its title for series search
|
||||||
|
let url = format!("https://openlibrary.org{}.json", external_id);
|
||||||
|
let resp = client.get(&url).send().await.map_err(|e| format!("Open Library request failed: {e}"))?;
|
||||||
|
|
||||||
|
let work: serde_json::Value = if resp.status().is_success() {
|
||||||
|
resp.json().await.map_err(|e| format!("Failed to parse response: {e}"))?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = work.get("title").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
let series_name = extract_series_name(title);
|
||||||
|
|
||||||
|
// Search for editions of this series
|
||||||
|
let search_url = format!(
|
||||||
|
"https://openlibrary.org/search.json?title={}&limit=40",
|
||||||
|
urlencoded(&series_name)
|
||||||
|
);
|
||||||
|
let resp = client.get(&search_url).send().await.map_err(|e| format!("Open Library search failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp.json().await.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||||
|
let docs = match data.get("docs").and_then(|d| d.as_array()) {
|
||||||
|
Some(docs) => docs,
|
||||||
|
None => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut books: Vec<BookCandidate> = docs
|
||||||
|
.iter()
|
||||||
|
.map(|doc| {
|
||||||
|
let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||||
|
let authors: Vec<String> = doc
|
||||||
|
.get("author_name")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let isbn = doc
|
||||||
|
.get("isbn")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let page_count = doc
|
||||||
|
.get("number_of_pages_median")
|
||||||
|
.and_then(|n| n.as_i64())
|
||||||
|
.map(|n| n as i32);
|
||||||
|
let cover_i = doc.get("cover_i").and_then(|c| c.as_i64());
|
||||||
|
let cover_url = cover_i.map(|id| format!("https://covers.openlibrary.org/b/id/{}-M.jpg", id));
|
||||||
|
let language = doc
|
||||||
|
.get("language")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let publish_date = doc
|
||||||
|
.get("first_publish_year")
|
||||||
|
.and_then(|y| y.as_i64())
|
||||||
|
.map(|y| y.to_string());
|
||||||
|
let key = doc.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();
|
||||||
|
let volume_number = extract_volume_number(&title);
|
||||||
|
|
||||||
|
BookCandidate {
|
||||||
|
external_book_id: key,
|
||||||
|
title,
|
||||||
|
volume_number,
|
||||||
|
authors,
|
||||||
|
isbn,
|
||||||
|
summary: None,
|
||||||
|
cover_url,
|
||||||
|
page_count,
|
||||||
|
language,
|
||||||
|
publish_date,
|
||||||
|
metadata_json: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
books.sort_by_key(|b| b.volume_number.unwrap_or(999));
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_series_name(title: &str) -> String {
|
||||||
|
let re_patterns = [
|
||||||
|
r"(?i)\s*[-–—]\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||||
|
r"(?i)\s*,?\s*(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*\d+.*$",
|
||||||
|
r"\s*\(\d+\)\s*$",
|
||||||
|
r"\s+\d+\s*$",
|
||||||
|
];
|
||||||
|
let mut result = title.to_string();
|
||||||
|
for pattern in &re_patterns {
|
||||||
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
|
let cleaned = re.replace(&result, "").to_string();
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
result = cleaned;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_volume_number(title: &str) -> Option<i32> {
|
||||||
|
let patterns = [
|
||||||
|
r"(?i)(?:vol(?:ume)?\.?\s*|tome\s*|t\.\s*|#)\s*(\d+)",
|
||||||
|
r"\((\d+)\)\s*$",
|
||||||
|
r"\b(\d+)\s*$",
|
||||||
|
];
|
||||||
|
for pattern in &patterns {
|
||||||
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
|
if let Some(caps) = re.captures(title) {
|
||||||
|
if let Some(num) = caps.get(1).and_then(|m| m.as_str().parse::<i32>().ok()) {
|
||||||
|
return Some(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(title: &str, query: &str) -> f32 {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
if title_lower == query {
|
||||||
|
1.0
|
||||||
|
} else if title_lower.starts_with(query) || query.starts_with(&title_lower) {
|
||||||
|
0.8
|
||||||
|
} else if title_lower.contains(query) || query.contains(&title_lower) {
|
||||||
|
0.7
|
||||||
|
} else {
|
||||||
|
let common: usize = query.chars().filter(|c| title_lower.contains(*c)).count();
|
||||||
|
let max_len = query.len().max(title_lower.len()).max(1);
|
||||||
|
(common as f32 / max_len as f32).clamp(0.1, 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for byte in s.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
result.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => result.push_str(&format!("%{:02X}", byte)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeriesCandidateBuilder {
|
||||||
|
title: String,
|
||||||
|
authors: Vec<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
publishers: Vec<String>,
|
||||||
|
start_year: Option<i32>,
|
||||||
|
volume_count: i32,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
external_id: String,
|
||||||
|
external_url: Option<String>,
|
||||||
|
}
|
||||||
825
apps/api/src/metadata_refresh.rs
Normal file
825
apps/api/src/metadata_refresh.rs
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{error::ApiError, metadata_providers, state::AppState};
|
||||||
|
use crate::metadata_batch::{load_provider_config_from_pool, is_job_cancelled, update_progress};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct MetadataRefreshRequest {
|
||||||
|
pub library_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single field change: old → new
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct FieldDiff {
|
||||||
|
field: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
old: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
new: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-book changes
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct BookDiff {
|
||||||
|
book_id: String,
|
||||||
|
title: String,
|
||||||
|
volume: Option<i32>,
|
||||||
|
changes: Vec<FieldDiff>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-series change report
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct SeriesRefreshResult {
|
||||||
|
series_name: String,
|
||||||
|
provider: String,
|
||||||
|
status: String, // "updated", "unchanged", "error"
|
||||||
|
series_changes: Vec<FieldDiff>,
|
||||||
|
book_changes: Vec<BookDiff>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for the report endpoint
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct MetadataRefreshReportDto {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub job_id: Uuid,
|
||||||
|
pub status: String,
|
||||||
|
pub total_links: i64,
|
||||||
|
pub refreshed: i64,
|
||||||
|
pub unchanged: i64,
|
||||||
|
pub errors: i64,
|
||||||
|
pub changes: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /metadata/refresh — Trigger a metadata refresh job
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/metadata/refresh",
|
||||||
|
tag = "metadata",
|
||||||
|
request_body = MetadataRefreshRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Job created"),
|
||||||
|
(status = 400, description = "Bad request"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn start_refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<MetadataRefreshRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let library_id: Uuid = body
|
||||||
|
.library_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
|
||||||
|
|
||||||
|
// Verify library exists
|
||||||
|
sqlx::query("SELECT 1 FROM libraries WHERE id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("library not found"))?;
|
||||||
|
|
||||||
|
// Check no existing running metadata_refresh job for this library
|
||||||
|
let existing: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM index_jobs WHERE library_id = $1 AND type = 'metadata_refresh' AND status IN ('pending', 'running') LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(existing_id) = existing {
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"id": existing_id.to_string(),
|
||||||
|
"status": "already_running",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check there are approved links to refresh
|
||||||
|
let link_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if link_count == 0 {
|
||||||
|
return Err(ApiError::bad_request("No approved metadata links to refresh for this library"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let job_id = Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(library_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Spawn the background processing task
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||||
|
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(e.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
notifications::notify(
|
||||||
|
pool.clone(),
|
||||||
|
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||||
|
library_name,
|
||||||
|
error: e.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": job_id.to_string(),
|
||||||
|
"status": "pending",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /metadata/refresh/:id/report — Refresh report from stats_json
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/metadata/refresh/{id}/report",
|
||||||
|
tag = "metadata",
|
||||||
|
params(("id" = String, Path, description = "Job UUID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = MetadataRefreshReportDto),
|
||||||
|
(status = 404, description = "Job not found"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_refresh_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(job_id): AxumPath<Uuid>,
|
||||||
|
) -> Result<Json<MetadataRefreshReportDto>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT status, stats_json, total_files FROM index_jobs WHERE id = $1 AND type = 'metadata_refresh'",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("job not found"))?;
|
||||||
|
|
||||||
|
let job_status: String = row.get("status");
|
||||||
|
let stats: Option<serde_json::Value> = row.get("stats_json");
|
||||||
|
let total_files: Option<i32> = row.get("total_files");
|
||||||
|
|
||||||
|
let (refreshed, unchanged, errors, changes) = if let Some(ref s) = stats {
|
||||||
|
(
|
||||||
|
s.get("refreshed").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
s.get("unchanged").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
s.get("errors").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
s.get("changes").cloned().unwrap_or(serde_json::json!([])),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0, 0, 0, serde_json::json!([]))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(MetadataRefreshReportDto {
|
||||||
|
job_id,
|
||||||
|
status: job_status,
|
||||||
|
total_links: total_files.unwrap_or(0) as i64,
|
||||||
|
refreshed,
|
||||||
|
unchanged,
|
||||||
|
errors,
|
||||||
|
changes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Background processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn process_metadata_refresh(
|
||||||
|
pool: &PgPool,
|
||||||
|
job_id: Uuid,
|
||||||
|
library_id: Uuid,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Set job to running
|
||||||
|
sqlx::query("UPDATE index_jobs SET status = 'running', started_at = NOW() WHERE id = $1")
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get all approved links for this library
|
||||||
|
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT id, series_name, provider, external_id
|
||||||
|
FROM external_metadata_links
|
||||||
|
WHERE library_id = $1 AND status = 'approved'
|
||||||
|
ORDER BY series_name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let total = links.len() as i32;
|
||||||
|
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(total)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut processed = 0i32;
|
||||||
|
let mut refreshed = 0i32;
|
||||||
|
let mut unchanged = 0i32;
|
||||||
|
let mut errors = 0i32;
|
||||||
|
let mut all_results: Vec<SeriesRefreshResult> = Vec::new();
|
||||||
|
|
||||||
|
for (link_id, series_name, provider_name, external_id) in &links {
|
||||||
|
// Check cancellation
|
||||||
|
if is_job_cancelled(pool, job_id).await {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'cancelled', finished_at = NOW() WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match refresh_link(pool, *link_id, library_id, series_name, provider_name, external_id).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.status == "updated" {
|
||||||
|
refreshed += 1;
|
||||||
|
info!("[METADATA_REFRESH] job={job_id} updated series='{series_name}' via {provider_name}");
|
||||||
|
} else {
|
||||||
|
unchanged += 1;
|
||||||
|
}
|
||||||
|
all_results.push(result);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors += 1;
|
||||||
|
warn!("[METADATA_REFRESH] job={job_id} error on series='{series_name}': {e}");
|
||||||
|
all_results.push(SeriesRefreshResult {
|
||||||
|
series_name: series_name.clone(),
|
||||||
|
provider: provider_name.clone(),
|
||||||
|
status: "error".to_string(),
|
||||||
|
series_changes: vec![],
|
||||||
|
book_changes: vec![],
|
||||||
|
error: Some(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed += 1;
|
||||||
|
update_progress(pool, job_id, processed, total, series_name).await;
|
||||||
|
|
||||||
|
// Rate limit: 1s delay between provider calls
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only keep series that have changes or errors (filter out "unchanged")
|
||||||
|
let changes_only: Vec<&SeriesRefreshResult> = all_results
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.status != "unchanged")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build stats summary
|
||||||
|
let stats = serde_json::json!({
|
||||||
|
"total_links": total,
|
||||||
|
"refreshed": refreshed,
|
||||||
|
"unchanged": unchanged,
|
||||||
|
"errors": errors,
|
||||||
|
"changes": changes_only,
|
||||||
|
});
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, stats_json = $2 WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(stats)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
|
||||||
|
|
||||||
|
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
notifications::notify(
|
||||||
|
pool.clone(),
|
||||||
|
notifications::NotificationEvent::MetadataRefreshCompleted {
|
||||||
|
library_name,
|
||||||
|
refreshed,
|
||||||
|
unchanged,
|
||||||
|
errors,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh a single approved metadata link: re-fetch from provider, compare, sync, return diff
|
||||||
|
async fn refresh_link(
|
||||||
|
pool: &PgPool,
|
||||||
|
link_id: Uuid,
|
||||||
|
library_id: Uuid,
|
||||||
|
series_name: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
external_id: &str,
|
||||||
|
) -> Result<SeriesRefreshResult, String> {
|
||||||
|
let provider = metadata_providers::get_provider(provider_name)
|
||||||
|
.ok_or_else(|| format!("Unknown provider: {provider_name}"))?;
|
||||||
|
|
||||||
|
let config = load_provider_config_from_pool(pool, provider_name).await;
|
||||||
|
|
||||||
|
let mut series_changes: Vec<FieldDiff> = Vec::new();
|
||||||
|
let mut book_changes: Vec<BookDiff> = Vec::new();
|
||||||
|
|
||||||
|
// ── Series-level refresh ──────────────────────────────────────────────
|
||||||
|
let candidates = provider
|
||||||
|
.search_series(series_name, &config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("provider search error: {e}"))?;
|
||||||
|
|
||||||
|
let candidate = candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.external_id == external_id)
|
||||||
|
.or_else(|| candidates.first());
|
||||||
|
|
||||||
|
if let Some(candidate) = candidate {
|
||||||
|
// Update link metadata_json
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE external_metadata_links
|
||||||
|
SET metadata_json = $2,
|
||||||
|
total_volumes_external = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(link_id)
|
||||||
|
.bind(&candidate.metadata_json)
|
||||||
|
.bind(candidate.total_volumes)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Diff + sync series metadata
|
||||||
|
series_changes = sync_series_with_diff(pool, library_id, series_name, candidate).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Book-level refresh ────────────────────────────────────────────────
|
||||||
|
let books = provider
|
||||||
|
.get_series_books(external_id, &config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("provider books error: {e}"))?;
|
||||||
|
|
||||||
|
// Delete existing external_book_metadata for this link
|
||||||
|
sqlx::query("DELETE FROM external_book_metadata WHERE link_id = $1")
|
||||||
|
.bind(link_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Pre-fetch local books
|
||||||
|
let local_books: Vec<(Uuid, Option<i32>, String)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT id, volume, title FROM books
|
||||||
|
WHERE library_id = $1
|
||||||
|
AND COALESCE(NULLIF(series, ''), 'unclassified') = $2
|
||||||
|
ORDER BY volume NULLS LAST,
|
||||||
|
REGEXP_REPLACE(LOWER(title), '[0-9].*$', ''),
|
||||||
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
|
title ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(series_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let local_books_with_pos: Vec<(Uuid, i32, String)> = local_books
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, (id, vol, title))| (*id, vol.unwrap_or((idx + 1) as i32), title.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut matched_local_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for (ext_idx, book) in books.iter().enumerate() {
|
||||||
|
let ext_vol = book.volume_number.unwrap_or((ext_idx + 1) as i32);
|
||||||
|
|
||||||
|
// Match by volume number
|
||||||
|
let mut local_book_id: Option<Uuid> = local_books_with_pos
|
||||||
|
.iter()
|
||||||
|
.find(|(id, v, _)| *v == ext_vol && !matched_local_ids.contains(id))
|
||||||
|
.map(|(id, _, _)| *id);
|
||||||
|
|
||||||
|
// Match by title containment
|
||||||
|
if local_book_id.is_none() {
|
||||||
|
let ext_title_lower = book.title.to_lowercase();
|
||||||
|
local_book_id = local_books_with_pos
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _, local_title)| {
|
||||||
|
if matched_local_ids.contains(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let local_lower = local_title.to_lowercase();
|
||||||
|
local_lower.contains(&ext_title_lower) || ext_title_lower.contains(&local_lower)
|
||||||
|
})
|
||||||
|
.map(|(id, _, _)| *id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = local_book_id {
|
||||||
|
matched_local_ids.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert external_book_metadata
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO external_book_metadata
|
||||||
|
(link_id, book_id, external_book_id, volume_number, title, authors, isbn, summary, cover_url, page_count, language, publish_date, metadata_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(link_id)
|
||||||
|
.bind(local_book_id)
|
||||||
|
.bind(&book.external_book_id)
|
||||||
|
.bind(book.volume_number)
|
||||||
|
.bind(&book.title)
|
||||||
|
.bind(&book.authors)
|
||||||
|
.bind(&book.isbn)
|
||||||
|
.bind(&book.summary)
|
||||||
|
.bind(&book.cover_url)
|
||||||
|
.bind(book.page_count)
|
||||||
|
.bind(&book.language)
|
||||||
|
.bind(&book.publish_date)
|
||||||
|
.bind(&book.metadata_json)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Diff + push metadata to matched local book
|
||||||
|
if let Some(book_id) = local_book_id {
|
||||||
|
let diffs = sync_book_with_diff(pool, book_id, book).await?;
|
||||||
|
if !diffs.is_empty() {
|
||||||
|
let local_title = local_books_with_pos
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _, _)| *id == book_id)
|
||||||
|
.map(|(_, _, t)| t.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
book_changes.push(BookDiff {
|
||||||
|
book_id: book_id.to_string(),
|
||||||
|
title: local_title,
|
||||||
|
volume: book.volume_number,
|
||||||
|
changes: diffs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update synced_at on the link
|
||||||
|
sqlx::query("UPDATE external_metadata_links SET synced_at = NOW(), updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(link_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let has_changes = !series_changes.is_empty() || !book_changes.is_empty();
|
||||||
|
|
||||||
|
Ok(SeriesRefreshResult {
|
||||||
|
series_name: series_name.to_string(),
|
||||||
|
provider: provider_name.to_string(),
|
||||||
|
status: if has_changes { "updated".to_string() } else { "unchanged".to_string() },
|
||||||
|
series_changes,
|
||||||
|
book_changes,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Diff helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compare old/new for a nullable string field. Returns Some(FieldDiff) only if value actually changed.
|
||||||
|
fn diff_opt_str(field: &str, old: Option<&str>, new: Option<&str>) -> Option<FieldDiff> {
|
||||||
|
let new_val = new.filter(|s| !s.is_empty());
|
||||||
|
// Only report a change if there is a new non-empty value AND it differs from old
|
||||||
|
match (old, new_val) {
|
||||||
|
(Some(o), Some(n)) if o != n => Some(FieldDiff {
|
||||||
|
field: field.to_string(),
|
||||||
|
old: Some(serde_json::Value::String(o.to_string())),
|
||||||
|
new: Some(serde_json::Value::String(n.to_string())),
|
||||||
|
}),
|
||||||
|
(None, Some(n)) => Some(FieldDiff {
|
||||||
|
field: field.to_string(),
|
||||||
|
old: None,
|
||||||
|
new: Some(serde_json::Value::String(n.to_string())),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_opt_i32(field: &str, old: Option<i32>, new: Option<i32>) -> Option<FieldDiff> {
|
||||||
|
match (old, new) {
|
||||||
|
(Some(o), Some(n)) if o != n => Some(FieldDiff {
|
||||||
|
field: field.to_string(),
|
||||||
|
old: Some(serde_json::json!(o)),
|
||||||
|
new: Some(serde_json::json!(n)),
|
||||||
|
}),
|
||||||
|
(None, Some(n)) => Some(FieldDiff {
|
||||||
|
field: field.to_string(),
|
||||||
|
old: None,
|
||||||
|
new: Some(serde_json::json!(n)),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_str_vec(field: &str, old: &[String], new: &[String]) -> Option<FieldDiff> {
|
||||||
|
if new.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if old != new {
|
||||||
|
Some(FieldDiff {
|
||||||
|
field: field.to_string(),
|
||||||
|
old: Some(serde_json::json!(old)),
|
||||||
|
new: Some(serde_json::json!(new)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Series sync with diff tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn sync_series_with_diff(
|
||||||
|
pool: &PgPool,
|
||||||
|
library_id: Uuid,
|
||||||
|
series_name: &str,
|
||||||
|
candidate: &metadata_providers::SeriesCandidate,
|
||||||
|
) -> Result<Vec<FieldDiff>, String> {
|
||||||
|
let new_description = candidate.metadata_json
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.or(candidate.description.as_deref());
|
||||||
|
let new_authors = &candidate.authors;
|
||||||
|
let new_publishers = &candidate.publishers;
|
||||||
|
let new_start_year = candidate.start_year;
|
||||||
|
let new_total_volumes = candidate.total_volumes;
|
||||||
|
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
|
||||||
|
Some(crate::metadata::normalize_series_status(pool, raw).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let new_status = new_status.as_deref();
|
||||||
|
|
||||||
|
// Fetch existing series metadata for diffing
|
||||||
|
let existing = sqlx::query(
|
||||||
|
r#"SELECT description, publishers, start_year, total_volumes, status, authors, locked_fields
|
||||||
|
FROM series_metadata WHERE library_id = $1 AND name = $2"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(series_name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let locked = existing
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.get::<serde_json::Value, _>("locked_fields"))
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
let is_locked = |field: &str| -> bool {
|
||||||
|
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build diffs (only for unlocked fields that actually change)
|
||||||
|
let mut diffs: Vec<FieldDiff> = Vec::new();
|
||||||
|
|
||||||
|
if !is_locked("description") {
|
||||||
|
let old_desc: Option<String> = existing.as_ref().and_then(|r| r.get("description"));
|
||||||
|
if let Some(d) = diff_opt_str("description", old_desc.as_deref(), new_description) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("authors") {
|
||||||
|
let old_authors: Vec<String> = existing.as_ref().map(|r| r.get("authors")).unwrap_or_default();
|
||||||
|
if let Some(d) = diff_str_vec("authors", &old_authors, new_authors) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("publishers") {
|
||||||
|
let old_publishers: Vec<String> = existing.as_ref().map(|r| r.get("publishers")).unwrap_or_default();
|
||||||
|
if let Some(d) = diff_str_vec("publishers", &old_publishers, new_publishers) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("start_year") {
|
||||||
|
let old_year: Option<i32> = existing.as_ref().and_then(|r| r.get("start_year"));
|
||||||
|
if let Some(d) = diff_opt_i32("start_year", old_year, new_start_year) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("total_volumes") {
|
||||||
|
let old_vols: Option<i32> = existing.as_ref().and_then(|r| r.get("total_volumes"));
|
||||||
|
if let Some(d) = diff_opt_i32("total_volumes", old_vols, new_total_volumes) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("status") {
|
||||||
|
let old_status: Option<String> = existing.as_ref().and_then(|r| r.get("status"));
|
||||||
|
if let Some(d) = diff_opt_str("status", old_status.as_deref(), new_status) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now do the actual upsert
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO series_metadata (library_id, name, description, publishers, start_year, total_volumes, status, authors, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||||
|
ON CONFLICT (library_id, name)
|
||||||
|
DO UPDATE SET
|
||||||
|
description = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'description')::boolean IS TRUE THEN series_metadata.description
|
||||||
|
ELSE COALESCE(NULLIF(EXCLUDED.description, ''), series_metadata.description)
|
||||||
|
END,
|
||||||
|
publishers = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'publishers')::boolean IS TRUE THEN series_metadata.publishers
|
||||||
|
WHEN array_length(EXCLUDED.publishers, 1) > 0 THEN EXCLUDED.publishers
|
||||||
|
ELSE series_metadata.publishers
|
||||||
|
END,
|
||||||
|
start_year = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'start_year')::boolean IS TRUE THEN series_metadata.start_year
|
||||||
|
ELSE COALESCE(EXCLUDED.start_year, series_metadata.start_year)
|
||||||
|
END,
|
||||||
|
total_volumes = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'total_volumes')::boolean IS TRUE THEN series_metadata.total_volumes
|
||||||
|
ELSE COALESCE(EXCLUDED.total_volumes, series_metadata.total_volumes)
|
||||||
|
END,
|
||||||
|
status = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'status')::boolean IS TRUE THEN series_metadata.status
|
||||||
|
ELSE COALESCE(EXCLUDED.status, series_metadata.status)
|
||||||
|
END,
|
||||||
|
authors = CASE
|
||||||
|
WHEN (series_metadata.locked_fields->>'authors')::boolean IS TRUE THEN series_metadata.authors
|
||||||
|
WHEN array_length(EXCLUDED.authors, 1) > 0 THEN EXCLUDED.authors
|
||||||
|
ELSE series_metadata.authors
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.bind(series_name)
|
||||||
|
.bind(new_description)
|
||||||
|
.bind(new_publishers)
|
||||||
|
.bind(new_start_year)
|
||||||
|
.bind(new_total_volumes)
|
||||||
|
.bind(new_status)
|
||||||
|
.bind(new_authors)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(diffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Book sync with diff tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn sync_book_with_diff(
|
||||||
|
pool: &PgPool,
|
||||||
|
book_id: Uuid,
|
||||||
|
ext_book: &metadata_providers::BookCandidate,
|
||||||
|
) -> Result<Vec<FieldDiff>, String> {
|
||||||
|
// Fetch current book state
|
||||||
|
let current = sqlx::query(
|
||||||
|
"SELECT summary, isbn, publish_date, language, authors, locked_fields FROM books WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(book_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let locked = current.get::<serde_json::Value, _>("locked_fields");
|
||||||
|
let is_locked = |field: &str| -> bool {
|
||||||
|
locked.get(field).and_then(|v| v.as_bool()).unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build diffs
|
||||||
|
let mut diffs: Vec<FieldDiff> = Vec::new();
|
||||||
|
|
||||||
|
if !is_locked("summary") {
|
||||||
|
let old: Option<String> = current.get("summary");
|
||||||
|
if let Some(d) = diff_opt_str("summary", old.as_deref(), ext_book.summary.as_deref()) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("isbn") {
|
||||||
|
let old: Option<String> = current.get("isbn");
|
||||||
|
if let Some(d) = diff_opt_str("isbn", old.as_deref(), ext_book.isbn.as_deref()) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("publish_date") {
|
||||||
|
let old: Option<String> = current.get("publish_date");
|
||||||
|
if let Some(d) = diff_opt_str("publish_date", old.as_deref(), ext_book.publish_date.as_deref()) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("language") {
|
||||||
|
let old: Option<String> = current.get("language");
|
||||||
|
if let Some(d) = diff_opt_str("language", old.as_deref(), ext_book.language.as_deref()) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_locked("authors") {
|
||||||
|
let old: Vec<String> = current.get("authors");
|
||||||
|
if let Some(d) = diff_str_vec("authors", &old, &ext_book.authors) {
|
||||||
|
diffs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the actual update
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE books SET
|
||||||
|
summary = CASE
|
||||||
|
WHEN (locked_fields->>'summary')::boolean IS TRUE THEN summary
|
||||||
|
ELSE COALESCE(NULLIF($2, ''), summary)
|
||||||
|
END,
|
||||||
|
isbn = CASE
|
||||||
|
WHEN (locked_fields->>'isbn')::boolean IS TRUE THEN isbn
|
||||||
|
ELSE COALESCE(NULLIF($3, ''), isbn)
|
||||||
|
END,
|
||||||
|
publish_date = CASE
|
||||||
|
WHEN (locked_fields->>'publish_date')::boolean IS TRUE THEN publish_date
|
||||||
|
ELSE COALESCE(NULLIF($4, ''), publish_date)
|
||||||
|
END,
|
||||||
|
language = CASE
|
||||||
|
WHEN (locked_fields->>'language')::boolean IS TRUE THEN language
|
||||||
|
ELSE COALESCE(NULLIF($5, ''), language)
|
||||||
|
END,
|
||||||
|
authors = CASE
|
||||||
|
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN authors
|
||||||
|
WHEN CARDINALITY($6::text[]) > 0 THEN $6
|
||||||
|
ELSE authors
|
||||||
|
END,
|
||||||
|
author = CASE
|
||||||
|
WHEN (locked_fields->>'authors')::boolean IS TRUE THEN author
|
||||||
|
WHEN CARDINALITY($6::text[]) > 0 THEN $6[1]
|
||||||
|
ELSE author
|
||||||
|
END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(book_id)
|
||||||
|
.bind(&ext_book.summary)
|
||||||
|
.bind(&ext_book.isbn)
|
||||||
|
.bind(&ext_book.publish_date)
|
||||||
|
.bind(&ext_book.language)
|
||||||
|
.bind(&ext_book.authors)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(diffs)
|
||||||
|
}
|
||||||
@@ -10,14 +10,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::update_reading_progress,
|
crate::reading_progress::update_reading_progress,
|
||||||
crate::reading_progress::mark_series_read,
|
crate::reading_progress::mark_series_read,
|
||||||
crate::books::get_thumbnail,
|
crate::books::get_thumbnail,
|
||||||
crate::books::list_series,
|
crate::series::list_series,
|
||||||
crate::books::list_all_series,
|
crate::series::list_all_series,
|
||||||
crate::books::ongoing_series,
|
crate::series::ongoing_series,
|
||||||
crate::books::ongoing_books,
|
crate::series::ongoing_books,
|
||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
crate::books::update_book,
|
crate::books::update_book,
|
||||||
crate::books::get_series_metadata,
|
crate::series::get_series_metadata,
|
||||||
crate::books::update_series,
|
crate::series::update_series,
|
||||||
crate::pages::get_page,
|
crate::pages::get_page,
|
||||||
crate::search::search_books,
|
crate::search::search_books,
|
||||||
crate::index_jobs::enqueue_rebuild,
|
crate::index_jobs::enqueue_rebuild,
|
||||||
@@ -35,10 +35,12 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::delete_library,
|
crate::libraries::delete_library,
|
||||||
crate::libraries::scan_library,
|
crate::libraries::scan_library,
|
||||||
crate::libraries::update_monitoring,
|
crate::libraries::update_monitoring,
|
||||||
|
crate::libraries::update_metadata_provider,
|
||||||
crate::tokens::list_tokens,
|
crate::tokens::list_tokens,
|
||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
crate::tokens::delete_token,
|
crate::tokens::delete_token,
|
||||||
|
crate::authors::list_authors,
|
||||||
crate::stats::get_stats,
|
crate::stats::get_stats,
|
||||||
crate::settings::get_settings,
|
crate::settings::get_settings,
|
||||||
crate::settings::get_setting,
|
crate::settings::get_setting,
|
||||||
@@ -46,6 +48,30 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::clear_cache,
|
crate::settings::clear_cache,
|
||||||
crate::settings::get_cache_stats,
|
crate::settings::get_cache_stats,
|
||||||
crate::settings::get_thumbnail_stats,
|
crate::settings::get_thumbnail_stats,
|
||||||
|
crate::metadata::search_metadata,
|
||||||
|
crate::metadata::create_metadata_match,
|
||||||
|
crate::metadata::approve_metadata,
|
||||||
|
crate::metadata::reject_metadata,
|
||||||
|
crate::metadata::get_metadata_links,
|
||||||
|
crate::metadata::get_missing_books,
|
||||||
|
crate::metadata::delete_metadata_link,
|
||||||
|
crate::series::series_statuses,
|
||||||
|
crate::series::provider_statuses,
|
||||||
|
crate::settings::list_status_mappings,
|
||||||
|
crate::settings::upsert_status_mapping,
|
||||||
|
crate::settings::delete_status_mapping,
|
||||||
|
crate::prowlarr::search_prowlarr,
|
||||||
|
crate::prowlarr::test_prowlarr,
|
||||||
|
crate::qbittorrent::add_torrent,
|
||||||
|
crate::qbittorrent::test_qbittorrent,
|
||||||
|
crate::metadata_batch::start_batch,
|
||||||
|
crate::metadata_batch::get_batch_report,
|
||||||
|
crate::metadata_batch::get_batch_results,
|
||||||
|
crate::metadata_refresh::start_refresh,
|
||||||
|
crate::metadata_refresh::get_refresh_report,
|
||||||
|
crate::komga::sync_komga_read_books,
|
||||||
|
crate::komga::list_sync_reports,
|
||||||
|
crate::komga::get_sync_report,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -57,14 +83,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::UpdateReadingProgressRequest,
|
crate::reading_progress::UpdateReadingProgressRequest,
|
||||||
crate::reading_progress::MarkSeriesReadRequest,
|
crate::reading_progress::MarkSeriesReadRequest,
|
||||||
crate::reading_progress::MarkSeriesReadResponse,
|
crate::reading_progress::MarkSeriesReadResponse,
|
||||||
crate::books::SeriesItem,
|
crate::series::SeriesItem,
|
||||||
crate::books::SeriesPage,
|
crate::series::SeriesPage,
|
||||||
crate::books::ListAllSeriesQuery,
|
crate::series::ListAllSeriesQuery,
|
||||||
crate::books::OngoingQuery,
|
crate::series::OngoingQuery,
|
||||||
crate::books::UpdateBookRequest,
|
crate::books::UpdateBookRequest,
|
||||||
crate::books::SeriesMetadata,
|
crate::series::SeriesMetadata,
|
||||||
crate::books::UpdateSeriesRequest,
|
crate::series::UpdateSeriesRequest,
|
||||||
crate::books::UpdateSeriesResponse,
|
crate::series::UpdateSeriesResponse,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
crate::search::SearchResponse,
|
crate::search::SearchResponse,
|
||||||
@@ -79,6 +105,7 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::LibraryResponse,
|
crate::libraries::LibraryResponse,
|
||||||
crate::libraries::CreateLibraryRequest,
|
crate::libraries::CreateLibraryRequest,
|
||||||
crate::libraries::UpdateMonitoringRequest,
|
crate::libraries::UpdateMonitoringRequest,
|
||||||
|
crate::libraries::UpdateMetadataProviderRequest,
|
||||||
crate::tokens::CreateTokenRequest,
|
crate::tokens::CreateTokenRequest,
|
||||||
crate::tokens::TokenResponse,
|
crate::tokens::TokenResponse,
|
||||||
crate::tokens::CreatedTokenResponse,
|
crate::tokens::CreatedTokenResponse,
|
||||||
@@ -86,6 +113,11 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::ClearCacheResponse,
|
crate::settings::ClearCacheResponse,
|
||||||
crate::settings::CacheStats,
|
crate::settings::CacheStats,
|
||||||
crate::settings::ThumbnailStats,
|
crate::settings::ThumbnailStats,
|
||||||
|
crate::settings::StatusMappingDto,
|
||||||
|
crate::settings::UpsertStatusMappingRequest,
|
||||||
|
crate::authors::ListAuthorsQuery,
|
||||||
|
crate::authors::AuthorItem,
|
||||||
|
crate::authors::AuthorsPageResponse,
|
||||||
crate::stats::StatsResponse,
|
crate::stats::StatsResponse,
|
||||||
crate::stats::StatsOverview,
|
crate::stats::StatsOverview,
|
||||||
crate::stats::ReadingStatusStats,
|
crate::stats::ReadingStatusStats,
|
||||||
@@ -94,6 +126,37 @@ use utoipa::OpenApi;
|
|||||||
crate::stats::LibraryStats,
|
crate::stats::LibraryStats,
|
||||||
crate::stats::TopSeries,
|
crate::stats::TopSeries,
|
||||||
crate::stats::MonthlyAdditions,
|
crate::stats::MonthlyAdditions,
|
||||||
|
crate::stats::MetadataStats,
|
||||||
|
crate::stats::ProviderCount,
|
||||||
|
crate::metadata::ApproveRequest,
|
||||||
|
crate::metadata::ApproveResponse,
|
||||||
|
crate::metadata::SyncReport,
|
||||||
|
crate::metadata::SeriesSyncReport,
|
||||||
|
crate::metadata::BookSyncReport,
|
||||||
|
crate::metadata::FieldChange,
|
||||||
|
crate::metadata::MetadataSearchRequest,
|
||||||
|
crate::metadata::SeriesCandidateDto,
|
||||||
|
crate::metadata::MetadataMatchRequest,
|
||||||
|
crate::metadata::ExternalMetadataLinkDto,
|
||||||
|
crate::metadata::MissingBooksDto,
|
||||||
|
crate::metadata::MissingBookItem,
|
||||||
|
crate::qbittorrent::QBittorrentAddRequest,
|
||||||
|
crate::qbittorrent::QBittorrentAddResponse,
|
||||||
|
crate::qbittorrent::QBittorrentTestResponse,
|
||||||
|
crate::prowlarr::ProwlarrSearchRequest,
|
||||||
|
crate::prowlarr::ProwlarrRelease,
|
||||||
|
crate::prowlarr::ProwlarrCategory,
|
||||||
|
crate::prowlarr::ProwlarrSearchResponse,
|
||||||
|
crate::prowlarr::MissingVolumeInput,
|
||||||
|
crate::prowlarr::ProwlarrTestResponse,
|
||||||
|
crate::metadata_batch::MetadataBatchRequest,
|
||||||
|
crate::metadata_batch::MetadataBatchReportDto,
|
||||||
|
crate::metadata_batch::MetadataBatchResultDto,
|
||||||
|
crate::metadata_refresh::MetadataRefreshRequest,
|
||||||
|
crate::metadata_refresh::MetadataRefreshReportDto,
|
||||||
|
crate::komga::KomgaSyncRequest,
|
||||||
|
crate::komga::KomgaSyncResponse,
|
||||||
|
crate::komga::KomgaSyncReportSummary,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -101,12 +164,20 @@ use utoipa::OpenApi;
|
|||||||
("Bearer" = [])
|
("Bearer" = [])
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
(name = "books", description = "Book browsing, details and management"),
|
||||||
|
(name = "series", description = "Series browsing, filtering and management"),
|
||||||
|
(name = "search", description = "Full-text search across books and series"),
|
||||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
(name = "authors", description = "Author browsing and listing"),
|
||||||
|
(name = "stats", description = "Collection statistics and dashboard data"),
|
||||||
|
(name = "libraries", description = "Library listing, scanning, and management (create/delete/settings: Admin only)"),
|
||||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||||
|
(name = "metadata", description = "External metadata providers and matching (Admin only)"),
|
||||||
|
(name = "komga", description = "Komga read-status sync (Admin only)"),
|
||||||
(name = "tokens", description = "API token management (Admin only)"),
|
(name = "tokens", description = "API token management (Admin only)"),
|
||||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||||
|
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||||
|
(name = "qbittorrent", description = "qBittorrent download client integration (Admin only)"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon)
|
modifiers(&SecurityAddon)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -277,7 +277,17 @@ pub async fn get_page(
|
|||||||
let cache_dir2 = cache_dir_path.clone();
|
let cache_dir2 = cache_dir_path.clone();
|
||||||
let format2 = format;
|
let format2 = format;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
prefetch_page(state2, book_id, &abs_path2, next_page, format2, quality, width, filter, timeout_secs, &cache_dir2).await;
|
prefetch_page(state2, &PrefetchParams {
|
||||||
|
book_id,
|
||||||
|
abs_path: &abs_path2,
|
||||||
|
page: next_page,
|
||||||
|
format: format2,
|
||||||
|
quality,
|
||||||
|
width,
|
||||||
|
filter,
|
||||||
|
timeout_secs,
|
||||||
|
cache_dir: &cache_dir2,
|
||||||
|
}).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,19 +300,30 @@ pub async fn get_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
|
struct PrefetchParams<'a> {
|
||||||
async fn prefetch_page(
|
|
||||||
state: AppState,
|
|
||||||
book_id: Uuid,
|
book_id: Uuid,
|
||||||
abs_path: &str,
|
abs_path: &'a str,
|
||||||
page: u32,
|
page: u32,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
quality: u8,
|
quality: u8,
|
||||||
width: u32,
|
width: u32,
|
||||||
filter: image::imageops::FilterType,
|
filter: image::imageops::FilterType,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
cache_dir: &Path,
|
cache_dir: &'a Path,
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
/// Prefetch a single page into disk+memory cache (best-effort, ignores errors).
|
||||||
|
async fn prefetch_page(state: AppState, params: &PrefetchParams<'_>) {
|
||||||
|
let book_id = params.book_id;
|
||||||
|
let page = params.page;
|
||||||
|
let format = params.format;
|
||||||
|
let quality = params.quality;
|
||||||
|
let width = params.width;
|
||||||
|
let filter = params.filter;
|
||||||
|
let timeout_secs = params.timeout_secs;
|
||||||
|
let abs_path = params.abs_path;
|
||||||
|
let cache_dir = params.cache_dir;
|
||||||
|
|
||||||
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
|
let mem_key = format!("{book_id}:{page}:{}:{quality}:{width}", format.extension());
|
||||||
// Already in memory cache?
|
// Already in memory cache?
|
||||||
if state.page_cache.lock().await.contains(&mem_key) {
|
if state.page_cache.lock().await.contains(&mem_key) {
|
||||||
@@ -330,6 +351,7 @@ async fn prefetch_page(
|
|||||||
Some(ref e) if e == "cbz" => "cbz",
|
Some(ref e) if e == "cbz" => "cbz",
|
||||||
Some(ref e) if e == "cbr" => "cbr",
|
Some(ref e) if e == "cbr" => "cbr",
|
||||||
Some(ref e) if e == "pdf" => "pdf",
|
Some(ref e) if e == "pdf" => "pdf",
|
||||||
|
Some(ref e) if e == "epub" => "epub",
|
||||||
_ => return,
|
_ => return,
|
||||||
}
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -458,6 +480,7 @@ fn render_page(
|
|||||||
"cbz" => parsers::BookFormat::Cbz,
|
"cbz" => parsers::BookFormat::Cbz,
|
||||||
"cbr" => parsers::BookFormat::Cbr,
|
"cbr" => parsers::BookFormat::Cbr,
|
||||||
"pdf" => parsers::BookFormat::Pdf,
|
"pdf" => parsers::BookFormat::Pdf,
|
||||||
|
"epub" => parsers::BookFormat::Epub,
|
||||||
_ => return Err(ApiError::bad_request("unsupported source format")),
|
_ => return Err(ApiError::bad_request("unsupported source format")),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
363
apps/api/src/prowlarr.rs
Normal file
363
apps/api/src/prowlarr.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct MissingVolumeInput {
|
||||||
|
pub volume_number: Option<i32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ProwlarrSearchRequest {
|
||||||
|
pub series_name: String,
|
||||||
|
pub volume_number: Option<i32>,
|
||||||
|
pub custom_query: Option<String>,
|
||||||
|
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProwlarrRawRelease {
|
||||||
|
pub guid: String,
|
||||||
|
pub title: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
pub indexer: Option<String>,
|
||||||
|
pub seeders: Option<i32>,
|
||||||
|
pub leechers: Option<i32>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub info_url: Option<String>,
|
||||||
|
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProwlarrRelease {
|
||||||
|
pub guid: String,
|
||||||
|
pub title: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
pub indexer: Option<String>,
|
||||||
|
pub seeders: Option<i32>,
|
||||||
|
pub leechers: Option<i32>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub info_url: Option<String>,
|
||||||
|
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProwlarrCategory {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ProwlarrSearchResponse {
|
||||||
|
pub results: Vec<ProwlarrRelease>,
|
||||||
|
pub query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ProwlarrTestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub indexer_count: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProwlarrConfig {
|
||||||
|
url: String,
|
||||||
|
api_key: String,
|
||||||
|
categories: Option<Vec<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_prowlarr_config(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<(String, String, Vec<i32>), ApiError> {
|
||||||
|
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'prowlarr'")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = row.ok_or_else(|| ApiError::bad_request("Prowlarr is not configured"))?;
|
||||||
|
let value: serde_json::Value = row.get("value");
|
||||||
|
let config: ProwlarrConfig = serde_json::from_value(value)
|
||||||
|
.map_err(|e| ApiError::internal(format!("invalid prowlarr config: {e}")))?;
|
||||||
|
|
||||||
|
if config.url.is_empty() || config.api_key.is_empty() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Prowlarr URL and API key must be configured in settings",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.url.trim_end_matches('/').to_string();
|
||||||
|
let categories = config.categories.unwrap_or_else(|| vec![7030, 7020]);
|
||||||
|
|
||||||
|
Ok((url, config.api_key, categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Volume matching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract volume numbers from a release title.
|
||||||
|
/// Looks for patterns like: T01, Tome 01, Vol. 01, v01, #01,
|
||||||
|
/// or standalone numbers that appear after common separators.
|
||||||
|
fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
||||||
|
let lower = title.to_lowercase();
|
||||||
|
let mut volumes = Vec::new();
|
||||||
|
|
||||||
|
// Patterns: T01, Tome 01, Tome01, Vol 01, Vol.01, v01, #01
|
||||||
|
let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"];
|
||||||
|
let chars: Vec<char> = lower.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
for prefix in &prefixes {
|
||||||
|
let mut start = 0;
|
||||||
|
while let Some(pos) = lower[start..].find(prefix) {
|
||||||
|
let abs_pos = start + pos;
|
||||||
|
let after = abs_pos + prefix.len();
|
||||||
|
|
||||||
|
// For single-char prefixes (t, v, #), ensure it's at a word boundary
|
||||||
|
if prefix.len() == 1 && *prefix != "#" {
|
||||||
|
if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() {
|
||||||
|
start = after;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip optional spaces after prefix
|
||||||
|
let mut i = after;
|
||||||
|
while i < len && chars[i] == ' ' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read digits
|
||||||
|
let digit_start = i;
|
||||||
|
while i < len && chars[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i > digit_start {
|
||||||
|
if let Ok(num) = lower[digit_start..i].parse::<i32>() {
|
||||||
|
if !volumes.contains(&num) {
|
||||||
|
volumes.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match releases against missing volume numbers.
|
||||||
|
fn match_missing_volumes(
|
||||||
|
releases: Vec<ProwlarrRawRelease>,
|
||||||
|
missing: &[MissingVolumeInput],
|
||||||
|
) -> Vec<ProwlarrRelease> {
|
||||||
|
let missing_numbers: Vec<i32> = missing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| m.volume_number)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let matched = if missing_numbers.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let title_volumes = extract_volumes_from_title(&r.title);
|
||||||
|
let matched: Vec<i32> = title_volumes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|v| missing_numbers.contains(v))
|
||||||
|
.collect();
|
||||||
|
if matched.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(matched)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ProwlarrRelease {
|
||||||
|
guid: r.guid,
|
||||||
|
title: r.title,
|
||||||
|
size: r.size,
|
||||||
|
download_url: r.download_url,
|
||||||
|
indexer: r.indexer,
|
||||||
|
seeders: r.seeders,
|
||||||
|
leechers: r.leechers,
|
||||||
|
publish_date: r.publish_date,
|
||||||
|
protocol: r.protocol,
|
||||||
|
info_url: r.info_url,
|
||||||
|
categories: r.categories,
|
||||||
|
matched_missing_volumes: matched,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Search for releases on Prowlarr
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/prowlarr/search",
|
||||||
|
tag = "prowlarr",
|
||||||
|
request_body = ProwlarrSearchRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ProwlarrSearchResponse),
|
||||||
|
(status = 400, description = "Bad request or Prowlarr not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Prowlarr connection error"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn search_prowlarr(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<ProwlarrSearchRequest>,
|
||||||
|
) -> Result<Json<ProwlarrSearchResponse>, ApiError> {
|
||||||
|
let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let query = if let Some(custom) = &body.custom_query {
|
||||||
|
custom.clone()
|
||||||
|
} else if let Some(vol) = body.volume_number {
|
||||||
|
format!("\"{}\" {}", body.series_name, vol)
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", body.series_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let mut params: Vec<(&str, String)> = vec![
|
||||||
|
("query", query.clone()),
|
||||||
|
("type", "search".to_string()),
|
||||||
|
];
|
||||||
|
for cat in &categories {
|
||||||
|
params.push(("categories", cat.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{url}/api/v1/search"))
|
||||||
|
.query(¶ms)
|
||||||
|
.header("X-Api-Key", &api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Prowlarr request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::internal(format!(
|
||||||
|
"Prowlarr returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?;
|
||||||
|
|
||||||
|
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
|
||||||
|
|
||||||
|
let raw_releases: Vec<ProwlarrRawRelease> = serde_json::from_str(&raw_text)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to parse Prowlarr response: {e}");
|
||||||
|
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
||||||
|
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let results = if let Some(missing) = &body.missing_volumes {
|
||||||
|
match_missing_volumes(raw_releases, missing)
|
||||||
|
} else {
|
||||||
|
raw_releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ProwlarrRelease {
|
||||||
|
guid: r.guid,
|
||||||
|
title: r.title,
|
||||||
|
size: r.size,
|
||||||
|
download_url: r.download_url,
|
||||||
|
indexer: r.indexer,
|
||||||
|
seeders: r.seeders,
|
||||||
|
leechers: r.leechers,
|
||||||
|
publish_date: r.publish_date,
|
||||||
|
protocol: r.protocol,
|
||||||
|
info_url: r.info_url,
|
||||||
|
categories: r.categories,
|
||||||
|
matched_missing_volumes: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection to Prowlarr
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/prowlarr/test",
|
||||||
|
tag = "prowlarr",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ProwlarrTestResponse),
|
||||||
|
(status = 400, description = "Prowlarr not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn test_prowlarr(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ProwlarrTestResponse>, ApiError> {
|
||||||
|
let (url, api_key, _categories) = load_prowlarr_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{url}/api/v1/indexer"))
|
||||||
|
.header("X-Api-Key", &api_key)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
let indexers: Vec<serde_json::Value> = r.json().await.unwrap_or_default();
|
||||||
|
Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Connected successfully ({} indexers)", indexers.len()),
|
||||||
|
indexer_count: Some(indexers.len() as i32),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
let text = r.text().await.unwrap_or_default();
|
||||||
|
Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Prowlarr returned {status}: {text}"),
|
||||||
|
indexer_count: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(Json(ProwlarrTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Connection failed: {e}"),
|
||||||
|
indexer_count: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
218
apps/api/src/qbittorrent.rs
Normal file
218
apps/api/src/qbittorrent.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct QBittorrentAddRequest {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct QBittorrentAddResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct QBittorrentTestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct QBittorrentConfig {
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_qbittorrent_config(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> Result<(String, String, String), ApiError> {
|
||||||
|
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'qbittorrent'")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = row.ok_or_else(|| ApiError::bad_request("qBittorrent is not configured"))?;
|
||||||
|
let value: serde_json::Value = row.get("value");
|
||||||
|
let config: QBittorrentConfig = serde_json::from_value(value)
|
||||||
|
.map_err(|e| ApiError::internal(format!("invalid qbittorrent config: {e}")))?;
|
||||||
|
|
||||||
|
if config.url.is_empty() || config.username.is_empty() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"qBittorrent URL and username must be configured in settings",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.url.trim_end_matches('/').to_string();
|
||||||
|
Ok((url, config.username, config.password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Login helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn qbittorrent_login(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{base_url}/api/v2/auth/login"))
|
||||||
|
.form(&[("username", username), ("password", password)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("qBittorrent login request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::internal(format!(
|
||||||
|
"qBittorrent login failed ({status}): {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract SID from Set-Cookie header
|
||||||
|
let cookie_header = resp
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let sid = cookie_header
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.strip_prefix("SID="))
|
||||||
|
.ok_or_else(|| ApiError::internal("Failed to get SID cookie from qBittorrent"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Add a torrent to qBittorrent
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/qbittorrent/add",
|
||||||
|
tag = "qbittorrent",
|
||||||
|
request_body = QBittorrentAddRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = QBittorrentAddResponse),
|
||||||
|
(status = 400, description = "Bad request or qBittorrent not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "qBittorrent connection error"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn add_torrent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<QBittorrentAddRequest>,
|
||||||
|
) -> Result<Json<QBittorrentAddResponse>, ApiError> {
|
||||||
|
if body.url.is_empty() {
|
||||||
|
return Err(ApiError::bad_request("url is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let sid = qbittorrent_login(&client, &base_url, &username, &password).await?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{base_url}/api/v2/torrents/add"))
|
||||||
|
.header("Cookie", format!("SID={sid}"))
|
||||||
|
.form(&[("urls", &body.url)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
Ok(Json(QBittorrentAddResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Torrent added to qBittorrent".to_string(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
Ok(Json(QBittorrentAddResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("qBittorrent returned {status}: {text}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection to qBittorrent
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/qbittorrent/test",
|
||||||
|
tag = "qbittorrent",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = QBittorrentTestResponse),
|
||||||
|
(status = 400, description = "qBittorrent not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn test_qbittorrent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<QBittorrentTestResponse>, ApiError> {
|
||||||
|
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?;
|
||||||
|
|
||||||
|
let sid = match qbittorrent_login(&client, &base_url, &username, &password).await {
|
||||||
|
Ok(sid) => sid,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(Json(QBittorrentTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Login failed: {}", e.message),
|
||||||
|
version: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{base_url}/api/v2/app/version"))
|
||||||
|
.header("Cookie", format!("SID={sid}"))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
let version = r.text().await.unwrap_or_default();
|
||||||
|
Ok(Json(QBittorrentTestResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Connected successfully ({})", version.trim()),
|
||||||
|
version: Some(version.trim().to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
let status = r.status();
|
||||||
|
let text = r.text().await.unwrap_or_default();
|
||||||
|
Ok(Json(QBittorrentTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("qBittorrent returned {status}: {text}"),
|
||||||
|
version: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(Json(QBittorrentTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Connection failed: {e}"),
|
||||||
|
version: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,11 +43,11 @@ pub struct SearchResponse {
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/search",
|
path = "/search",
|
||||||
tag = "books",
|
tag = "search",
|
||||||
params(
|
params(
|
||||||
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf, epub)"),
|
||||||
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
||||||
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
|
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
|
||||||
),
|
),
|
||||||
|
|||||||
1028
apps/api/src/series.rs
Normal file
1028
apps/api/src/series.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path as AxumPath, State},
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
||||||
@@ -42,6 +43,14 @@ pub fn settings_routes() -> Router<AppState> {
|
|||||||
.route("/settings/cache/clear", post(clear_cache))
|
.route("/settings/cache/clear", post(clear_cache))
|
||||||
.route("/settings/cache/stats", get(get_cache_stats))
|
.route("/settings/cache/stats", get(get_cache_stats))
|
||||||
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
||||||
|
.route(
|
||||||
|
"/settings/status-mappings",
|
||||||
|
get(list_status_mappings).post(upsert_status_mapping),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/settings/status-mappings/:id",
|
||||||
|
delete(delete_status_mapping),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all settings
|
/// List all settings
|
||||||
@@ -324,3 +333,125 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
|
|||||||
|
|
||||||
Ok(Json(stats))
|
Ok(Json(stats))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Mappings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct StatusMappingDto {
|
||||||
|
pub id: String,
|
||||||
|
pub provider_status: String,
|
||||||
|
pub mapped_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct UpsertStatusMappingRequest {
|
||||||
|
pub provider_status: String,
|
||||||
|
pub mapped_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all status mappings
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/settings/status-mappings",
|
||||||
|
tag = "settings",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Vec<StatusMappingDto>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn list_status_mappings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mappings = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| StatusMappingDto {
|
||||||
|
id: row.get::<Uuid, _>("id").to_string(),
|
||||||
|
provider_status: row.get("provider_status"),
|
||||||
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(mappings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update a status mapping
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/settings/status-mappings",
|
||||||
|
tag = "settings",
|
||||||
|
request_body = UpsertStatusMappingRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = StatusMappingDto),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn upsert_status_mapping(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<UpsertStatusMappingRequest>,
|
||||||
|
) -> Result<Json<StatusMappingDto>, ApiError> {
|
||||||
|
let provider_status = body.provider_status.to_lowercase();
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO status_mappings (provider_status, mapped_status)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (provider_status)
|
||||||
|
DO UPDATE SET mapped_status = $2, updated_at = NOW()
|
||||||
|
RETURNING id, provider_status, mapped_status
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&provider_status)
|
||||||
|
.bind(&body.mapped_status)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(StatusMappingDto {
|
||||||
|
id: row.get::<Uuid, _>("id").to_string(),
|
||||||
|
provider_status: row.get("provider_status"),
|
||||||
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/settings/status-mappings/{id}",
|
||||||
|
tag = "settings",
|
||||||
|
params(("id" = String, Path, description = "Mapping UUID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = StatusMappingDto),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "Not found"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn delete_status_mapping(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<Uuid>,
|
||||||
|
) -> Result<Json<StatusMappingDto>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(row) => Ok(Json(StatusMappingDto {
|
||||||
|
id: row.get::<Uuid, _>("id").to_string(),
|
||||||
|
provider_status: row.get("provider_status"),
|
||||||
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||||
|
})),
|
||||||
|
None => Err(ApiError::not_found("status mapping not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
use serde::Serialize;
|
extract::{Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct StatsQuery {
|
||||||
|
/// Granularity: "day", "week" or "month" (default: "month")
|
||||||
|
pub period: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsOverview {
|
pub struct StatsOverview {
|
||||||
pub total_books: i64,
|
pub total_books: i64,
|
||||||
@@ -58,22 +67,76 @@ pub struct MonthlyAdditions {
|
|||||||
pub books_added: i64,
|
pub books_added: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct MetadataStats {
|
||||||
|
pub total_series: i64,
|
||||||
|
pub series_linked: i64,
|
||||||
|
pub series_unlinked: i64,
|
||||||
|
pub books_with_summary: i64,
|
||||||
|
pub books_with_isbn: i64,
|
||||||
|
pub by_provider: Vec<ProviderCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ProviderCount {
|
||||||
|
pub provider: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CurrentlyReadingItem {
|
||||||
|
pub book_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub series: Option<String>,
|
||||||
|
pub current_page: i32,
|
||||||
|
pub page_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct RecentlyReadItem {
|
||||||
|
pub book_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub series: Option<String>,
|
||||||
|
pub last_read_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct MonthlyReading {
|
||||||
|
pub month: String,
|
||||||
|
pub books_read: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct JobTimePoint {
|
||||||
|
pub label: String,
|
||||||
|
pub scan: i64,
|
||||||
|
pub rebuild: i64,
|
||||||
|
pub thumbnail: i64,
|
||||||
|
pub other: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsResponse {
|
pub struct StatsResponse {
|
||||||
pub overview: StatsOverview,
|
pub overview: StatsOverview,
|
||||||
pub reading_status: ReadingStatusStats,
|
pub reading_status: ReadingStatusStats,
|
||||||
|
pub currently_reading: Vec<CurrentlyReadingItem>,
|
||||||
|
pub recently_read: Vec<RecentlyReadItem>,
|
||||||
|
pub reading_over_time: Vec<MonthlyReading>,
|
||||||
pub by_format: Vec<FormatCount>,
|
pub by_format: Vec<FormatCount>,
|
||||||
pub by_language: Vec<LanguageCount>,
|
pub by_language: Vec<LanguageCount>,
|
||||||
pub by_library: Vec<LibraryStats>,
|
pub by_library: Vec<LibraryStats>,
|
||||||
pub top_series: Vec<TopSeries>,
|
pub top_series: Vec<TopSeries>,
|
||||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
|
pub jobs_over_time: Vec<JobTimePoint>,
|
||||||
|
pub metadata: MetadataStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get collection statistics for the dashboard
|
/// Get collection statistics for the dashboard
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/stats",
|
path = "/stats",
|
||||||
tag = "books",
|
tag = "stats",
|
||||||
|
params(StatsQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = StatsResponse),
|
(status = 200, body = StatsResponse),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
@@ -82,7 +145,9 @@ pub struct StatsResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_stats(
|
pub async fn get_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<StatsQuery>,
|
||||||
) -> Result<Json<StatsResponse>, ApiError> {
|
) -> Result<Json<StatsResponse>, ApiError> {
|
||||||
|
let period = query.period.as_deref().unwrap_or("month");
|
||||||
// Overview + reading status in one query
|
// Overview + reading status in one query
|
||||||
let overview_row = sqlx::query(
|
let overview_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -242,20 +307,74 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Additions over time (last 12 months)
|
// Additions over time (with gap filling)
|
||||||
let additions_rows = sqlx::query(
|
let additions_rows = match period {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_added
|
SELECT
|
||||||
FROM books
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
GROUP BY DATE_TRUNC('month', created_at)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
ORDER BY month ASC
|
LEFT JOIN (
|
||||||
"#,
|
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||||
)
|
FROM books
|
||||||
.fetch_all(&state.pool)
|
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
GROUP BY created_at::date
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -265,13 +384,318 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Metadata stats
|
||||||
|
let meta_row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(DISTINCT NULLIF(series, '')) FROM books) AS total_series,
|
||||||
|
(SELECT COUNT(DISTINCT series_name) FROM external_metadata_links WHERE status = 'approved') AS series_linked,
|
||||||
|
(SELECT COUNT(*) FROM books WHERE summary IS NOT NULL AND summary != '') AS books_with_summary,
|
||||||
|
(SELECT COUNT(*) FROM books WHERE isbn IS NOT NULL AND isbn != '') AS books_with_isbn
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let meta_total_series: i64 = meta_row.get("total_series");
|
||||||
|
let meta_series_linked: i64 = meta_row.get("series_linked");
|
||||||
|
|
||||||
|
let provider_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT provider, COUNT(DISTINCT series_name) AS count
|
||||||
|
FROM external_metadata_links
|
||||||
|
WHERE status = 'approved'
|
||||||
|
GROUP BY provider
|
||||||
|
ORDER BY count DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let by_provider: Vec<ProviderCount> = provider_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| ProviderCount {
|
||||||
|
provider: r.get("provider"),
|
||||||
|
count: r.get("count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let metadata = MetadataStats {
|
||||||
|
total_series: meta_total_series,
|
||||||
|
series_linked: meta_series_linked,
|
||||||
|
series_unlinked: meta_total_series - meta_series_linked,
|
||||||
|
books_with_summary: meta_row.get("books_with_summary"),
|
||||||
|
books_with_isbn: meta_row.get("books_with_isbn"),
|
||||||
|
by_provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Currently reading books
|
||||||
|
let reading_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
|
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
||||||
|
ORDER BY brp.updated_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let currently_reading: Vec<CurrentlyReadingItem> = reading_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let id: uuid::Uuid = r.get("book_id");
|
||||||
|
CurrentlyReadingItem {
|
||||||
|
book_id: id.to_string(),
|
||||||
|
title: r.get("title"),
|
||||||
|
series: r.get("series"),
|
||||||
|
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
||||||
|
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Recently read books
|
||||||
|
let recent_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT b.id AS book_id, b.title, b.series,
|
||||||
|
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
|
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
||||||
|
ORDER BY brp.last_read_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let recently_read: Vec<RecentlyReadItem> = recent_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let id: uuid::Uuid = r.get("book_id");
|
||||||
|
RecentlyReadItem {
|
||||||
|
book_id: id.to_string(),
|
||||||
|
title: r.get("title"),
|
||||||
|
series: r.get("series"),
|
||||||
|
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Reading activity over time (with gap filling)
|
||||||
|
let reading_time_rows = match period {
|
||||||
|
"day" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
|
GROUP BY brp.last_read_at::date
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| MonthlyReading {
|
||||||
|
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||||
|
books_read: r.get("books_read"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Jobs over time (with gap filling, grouped by type category)
|
||||||
|
let jobs_rows = match period {
|
||||||
|
"day" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
finished_at::date AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
|
GROUP BY finished_at::date, cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('week', finished_at) AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', finished_at), cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', finished_at) AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', finished_at), cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| JobTimePoint {
|
||||||
|
label: r.get("label"),
|
||||||
|
scan: r.get("scan"),
|
||||||
|
rebuild: r.get("rebuild"),
|
||||||
|
thumbnail: r.get("thumbnail"),
|
||||||
|
other: r.get("other"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(StatsResponse {
|
Ok(Json(StatsResponse {
|
||||||
overview,
|
overview,
|
||||||
reading_status,
|
reading_status,
|
||||||
|
currently_reading,
|
||||||
|
recently_read,
|
||||||
|
reading_over_time,
|
||||||
by_format,
|
by_format,
|
||||||
by_language,
|
by_language,
|
||||||
by_library,
|
by_library,
|
||||||
top_series,
|
top_series,
|
||||||
additions_over_time,
|
additions_over_time,
|
||||||
|
jobs_over_time,
|
||||||
|
metadata,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/api/src/telegram.rs
Normal file
46
apps/api/src/telegram.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct TelegramTestResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Telegram connection by sending a test message
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/telegram/test",
|
||||||
|
tag = "notifications",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = TelegramTestResponse),
|
||||||
|
(status = 400, description = "Telegram not configured"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn test_telegram(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<TelegramTestResponse>, ApiError> {
|
||||||
|
let config = notifications::load_telegram_config(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::bad_request(
|
||||||
|
"Telegram is not configured or disabled. Set bot_token, chat_id, and enable it.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match notifications::send_test_message(&config).await {
|
||||||
|
Ok(()) => Ok(Json(TelegramTestResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Test message sent successfully".to_string(),
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(TelegramTestResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Failed to send: {e}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,9 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le content-type et les données
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
// Retourner l'image avec le bon content-type
|
return new NextResponse(response.body, {
|
||||||
return new NextResponse(imageBuffer, {
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=300",
|
"Cache-Control": "public, max-age=300",
|
||||||
|
|||||||
@@ -9,10 +9,25 @@ export async function GET(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
|
|
||||||
|
const fetchHeaders: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (ifNoneMatch) {
|
||||||
|
fetchHeaders["If-None-Match"] = ifNoneMatch;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: fetchHeaders,
|
||||||
|
next: { revalidate: 86400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Forward 304 Not Modified as-is
|
||||||
|
if (response.status === 304) {
|
||||||
|
return new NextResponse(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||||
status: response.status
|
status: response.status
|
||||||
@@ -20,14 +35,17 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
const etag = response.headers.get("etag");
|
||||||
|
|
||||||
return new NextResponse(imageBuffer, {
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
"Content-Type": contentType,
|
||||||
"Content-Type": contentType,
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
};
|
||||||
},
|
if (etag) {
|
||||||
});
|
headers["ETag"] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(response.body, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching thumbnail:", error);
|
console.error("Error fetching thumbnail:", error);
|
||||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function GET(request: NextRequest) {
|
|||||||
let lastData: string | null = null;
|
let lastData: string | null = null;
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
@@ -25,23 +26,28 @@ export async function GET(request: NextRequest) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const dataStr = JSON.stringify(data);
|
const dataStr = JSON.stringify(data);
|
||||||
|
|
||||||
// Send if data changed
|
// Send only if data changed
|
||||||
if (dataStr !== lastData && isActive) {
|
if (dataStr !== lastData && isActive) {
|
||||||
lastData = dataStr;
|
lastData = dataStr;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Controller closed, ignore
|
|
||||||
isActive = false;
|
isActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adapt interval: 2s when active jobs exist, 15s when idle
|
||||||
|
const hasActiveJobs = data.some((j: { status: string }) =>
|
||||||
|
j.status === "running" || j.status === "pending" || j.status === "extracting_pages" || j.status === "generating_thumbnails"
|
||||||
|
);
|
||||||
|
const nextInterval = hasActiveJobs ? 2000 : 15000;
|
||||||
|
restartInterval(nextInterval);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
consecutiveErrors++;
|
consecutiveErrors++;
|
||||||
// Only log first failure and every 30th to avoid spam
|
|
||||||
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
||||||
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
||||||
}
|
}
|
||||||
@@ -49,22 +55,18 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial fetch
|
const restartInterval = (ms: number) => {
|
||||||
await fetchJobs();
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
|
intervalId = setInterval(fetchJobs, ms);
|
||||||
|
};
|
||||||
|
|
||||||
// Poll every 2 seconds
|
// Initial fetch + start polling
|
||||||
const interval = setInterval(async () => {
|
await fetchJobs();
|
||||||
if (!isActive) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchJobs();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
request.signal.addEventListener("abort", () => {
|
request.signal.addEventListener("abort", () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
clearInterval(interval);
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
controller.close();
|
controller.close();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, LibraryDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<LibraryDto>(`/libraries/${id}/metadata-provider`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update metadata provider";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ export async function PATCH(
|
|||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const { monitor_enabled, scan_mode, watcher_enabled } = await request.json();
|
const { monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode } = await request.json();
|
||||||
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled);
|
const data = await updateLibraryMonitoring(id, monitor_enabled, scan_mode, watcher_enabled, metadata_refresh_mode);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to update monitoring settings";
|
const message = error instanceof Error ? error.message : "Failed to update monitoring settings";
|
||||||
|
|||||||
17
apps/backoffice/app/api/metadata/approve/route.ts
Normal file
17
apps/backoffice/app/api/metadata/approve/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, ...rest } = body;
|
||||||
|
const data = await apiFetch<{ status: string; books_synced: number }>(`/metadata/approve/${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(rest),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to approve metadata";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, MetadataBatchReportDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await apiFetch<MetadataBatchReportDto>(`/metadata/batch/${id}/report`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, MetadataBatchResultDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const status = searchParams.get("status") || "";
|
||||||
|
const params = status ? `?status=${status}` : "";
|
||||||
|
const data = await apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${id}/results${params}`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch results";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<{ id: string; status: string }>("/metadata/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to start batch";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/backoffice/app/api/metadata/links/route.ts
Normal file
35
apps/backoffice/app/api/metadata/links/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const libraryId = searchParams.get("library_id") || "";
|
||||||
|
const seriesName = searchParams.get("series_name") || "";
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
|
if (seriesName) params.set("series_name", seriesName);
|
||||||
|
const data = await apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch metadata links";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to delete metadata link";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/match/route.ts
Normal file
16
apps/backoffice/app/api/metadata/match/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<ExternalMetadataLinkDto>("/metadata/match", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to create metadata match";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/backoffice/app/api/metadata/missing/route.ts
Normal file
17
apps/backoffice/app/api/metadata/missing/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, MissingBooksDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await apiFetch<MissingBooksDto>(`/metadata/missing/${id}`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch missing books";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/refresh/report/route.ts
Normal file
16
apps/backoffice/app/api/metadata/refresh/report/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const jobId = request.nextUrl.searchParams.get("job_id");
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: "job_id required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await apiFetch(`/metadata/refresh/${jobId}/report`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to get report";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/refresh/route.ts
Normal file
16
apps/backoffice/app/api/metadata/refresh/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<{ id: string; status: string }>("/metadata/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to start refresh";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/backoffice/app/api/metadata/reject/route.ts
Normal file
15
apps/backoffice/app/api/metadata/reject/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<{ status: string }>(`/metadata/reject/${body.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to reject metadata";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/search/route.ts
Normal file
16
apps/backoffice/app/api/metadata/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, SeriesCandidateDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<SeriesCandidateDto[]>("/metadata/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to search metadata";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
16
apps/backoffice/app/api/prowlarr/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch("/prowlarr/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to search Prowlarr";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backoffice/app/api/prowlarr/test/route.ts
Normal file
12
apps/backoffice/app/api/prowlarr/test/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch("/prowlarr/test");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to test Prowlarr connection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
16
apps/backoffice/app/api/qbittorrent/add/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch("/qbittorrent/add", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to add torrent";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backoffice/app/api/qbittorrent/test/route.ts
Normal file
12
apps/backoffice/app/api/qbittorrent/test/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch("/qbittorrent/test");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to test qBittorrent";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/provider-statuses/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<string[]>("/series/provider-statuses");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json([], { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/backoffice/app/api/series/statuses/route.ts
Normal file
11
apps/backoffice/app/api/series/statuses/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<string[]>("/series/statuses");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json([], { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<unknown>(`/settings/status-mappings/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to delete status mapping" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal file
24
apps/backoffice/app/api/settings/status-mappings/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<unknown>("/settings/status-mappings");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch status mappings" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<unknown>("/settings/status-mappings", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to save status mapping" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backoffice/app/api/telegram/test/route.ts
Normal file
12
apps/backoffice/app/api/telegram/test/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch("/telegram/test");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to test Telegram connection";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apps/backoffice/app/authors/[name]/page.tsx
Normal file
135
apps/backoffice/app/authors/[name]/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
|
||||||
|
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||||
|
import { BooksGrid } from "../../components/BookCard";
|
||||||
|
import { OffsetPagination } from "../../components/ui";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AuthorDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ name: string }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
|
const { name: encodedName } = await params;
|
||||||
|
const authorName = decodeURIComponent(encodedName);
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
|
// Fetch books by this author (server-side filtering via API) and series by this author
|
||||||
|
const [booksPage, seriesPage] = await Promise.all([
|
||||||
|
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||||
|
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
||||||
|
),
|
||||||
|
fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch(
|
||||||
|
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
|
const authorSeries = seriesPage.items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
|
||||||
|
<Link href="/authors" className="hover:text-foreground transition-colors">
|
||||||
|
{t("authors.title")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-medium">{authorName}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Author Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-2xl font-bold text-accent-foreground">
|
||||||
|
{authorName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{authorName}</h1>
|
||||||
|
<div className="flex items-center gap-4 mt-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("authors.bookCount", { count: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
{authorSeries.length > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("authors.seriesCount", { count: String(authorSeries.length), plural: authorSeries.length !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series Section */}
|
||||||
|
{authorSeries.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
{t("authors.seriesBy", { name: authorName })}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
|
{authorSeries.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={`${s.library_id}-${s.name}`}
|
||||||
|
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200">
|
||||||
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
|
alt={s.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
|
{s.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("authors.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books Section */}
|
||||||
|
{booksPage.items.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
{t("authors.booksBy", { name: authorName })}
|
||||||
|
</h2>
|
||||||
|
<BooksGrid books={booksPage.items} />
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={booksPage.total}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{booksPage.items.length === 0 && authorSeries.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{t("authors.noResults")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/backoffice/app/authors/page.tsx
Normal file
122
apps/backoffice/app/authors/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
|
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||||
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AuthorsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
|
const authorsPage = await fetchAuthors(
|
||||||
|
searchQuery || undefined,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
).catch(() => ({ items: [], total: 0, page: 1, limit }) as AuthorsPageDto);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(authorsPage.total / limit);
|
||||||
|
const hasFilters = searchQuery || sort;
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: "", label: t("authors.sortName") },
|
||||||
|
{ value: "books", label: t("authors.sortBooks") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
{t("authors.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<LiveSearchForm
|
||||||
|
basePath="/authors"
|
||||||
|
fields={[
|
||||||
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("authors.searchPlaceholder") },
|
||||||
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{authorsPage.total} {t("authors.title").toLowerCase()}
|
||||||
|
{searchQuery && <> {t("authors.matchingQuery")} "{searchQuery}"</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Authors List */}
|
||||||
|
{authorsPage.items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{authorsPage.items.map((author) => (
|
||||||
|
<Link
|
||||||
|
key={author.name}
|
||||||
|
href={`/authors/${encodeURIComponent(author.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-lg font-semibold text-violet-500">
|
||||||
|
{author.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm group-hover:text-violet-500 transition-colors" title={author.name}>
|
||||||
|
{author.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("authors.bookCount", { count: String(author.book_count), plural: author.book_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("authors.seriesCount", { count: String(author.series_count), plural: author.series_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={authorsPage.total}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{hasFilters ? t("authors.noResults") : t("authors.noAuthors")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,44 +2,25 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro
|
|||||||
import { BookPreview } from "../../components/BookPreview";
|
import { BookPreview } from "../../components/BookPreview";
|
||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "../../components/ConvertButton";
|
||||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||||
import { EditBookForm } from "../../components/EditBookForm";
|
import nextDynamic from "next/dynamic";
|
||||||
|
import { SafeHtml } from "../../components/SafeHtml";
|
||||||
|
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EditBookForm = nextDynamic(
|
||||||
|
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
|
||||||
|
);
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
|
const readingStatusClassNames: Record<ReadingStatus, string> = {
|
||||||
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
|
unread: "bg-muted/60 text-muted-foreground border border-border",
|
||||||
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
|
reading: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30",
|
||||||
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
|
read: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ReadingStatusBadge({
|
|
||||||
status,
|
|
||||||
currentPage,
|
|
||||||
lastReadAt,
|
|
||||||
}: {
|
|
||||||
status: ReadingStatus;
|
|
||||||
currentPage: number | null;
|
|
||||||
lastReadAt: string | null;
|
|
||||||
}) {
|
|
||||||
const { label, className } = readingStatusConfig[status];
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${className}`}>
|
|
||||||
{label}
|
|
||||||
{status === "reading" && currentPage != null && ` · p. ${currentPage}`}
|
|
||||||
</span>
|
|
||||||
{lastReadAt && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(lastReadAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<BookDto>(`/books/${bookId}`);
|
return await apiFetch<BookDto>(`/books/${bookId}`);
|
||||||
@@ -63,164 +44,199 @@ export default async function BookDetailPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
const library = libraries.find(l => l.id === book.library_id);
|
const library = libraries.find(l => l.id === book.library_id);
|
||||||
|
const formatBadge = (book.format ?? book.kind).toUpperCase();
|
||||||
|
const formatColor =
|
||||||
|
formatBadge === "CBZ" ? "bg-success/10 text-success border-success/30" :
|
||||||
|
formatBadge === "CBR" ? "bg-warning/10 text-warning border-warning/30" :
|
||||||
|
formatBadge === "PDF" ? "bg-destructive/10 text-destructive border-destructive/30" :
|
||||||
|
"bg-muted/50 text-muted-foreground border-border";
|
||||||
|
const statusLabel = t(`status.${book.reading_status}` as "status.unread" | "status.reading" | "status.read");
|
||||||
|
const statusClassName = readingStatusClassNames[book.reading_status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
{/* Breadcrumb */}
|
||||||
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
← Back to books
|
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
{t("bookDetail.libraries")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
{library && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${book.library_id}/series`}
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{library.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{book.series && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{book.series}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-foreground font-medium truncate">{book.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
{/* Hero */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
|
{/* Cover */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
|
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(book.id)}
|
src={getBookCoverUrl(book.id)}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||||
width={300}
|
fill
|
||||||
height={440}
|
className="object-cover"
|
||||||
className="w-auto h-auto max-w-[300px] rounded-lg"
|
sizes="192px"
|
||||||
unoptimized
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* Info */}
|
||||||
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4 mb-2">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
||||||
<EditBookForm book={book} />
|
{book.author && (
|
||||||
</div>
|
<p className="text-base text-muted-foreground mt-1">{book.author}</p>
|
||||||
|
|
||||||
{book.author && (
|
|
||||||
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.series && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
|
||||||
{book.series}
|
|
||||||
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{book.reading_status && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Lecture :</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ReadingStatusBadge
|
|
||||||
status={book.reading_status}
|
|
||||||
currentPage={book.reading_current_page ?? null}
|
|
||||||
lastReadAt={book.reading_last_read_at ?? null}
|
|
||||||
/>
|
|
||||||
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Format:</span>
|
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' :
|
|
||||||
(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' :
|
|
||||||
(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' :
|
|
||||||
'bg-muted/50 text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{(book.format ?? book.kind).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{book.volume && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Volume:</span>
|
|
||||||
<span className="text-sm text-foreground">{book.volume}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.language && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Language:</span>
|
|
||||||
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.page_count && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Pages:</span>
|
|
||||||
<span className="text-sm text-foreground">{book.page_count}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Library:</span>
|
|
||||||
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{book.series && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Series:</span>
|
|
||||||
<span className="text-sm text-foreground">{book.series}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.file_format && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">File Format:</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
|
||||||
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.file_parse_status && (
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground">Parse Status:</span>
|
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
|
|
||||||
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{book.file_parse_status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{book.file_path && (
|
|
||||||
<div className="flex flex-col py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
|
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
|
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-border">
|
|
||||||
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
|
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{book.updated_at && (
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-muted-foreground">Updated:</span>
|
|
||||||
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<EditBookForm book={book} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Series + Volume link */}
|
||||||
|
{book.series && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||||
|
className="text-primary hover:text-primary/80 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{book.series}
|
||||||
|
</Link>
|
||||||
|
{book.volume != null && (
|
||||||
|
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-semibold">
|
||||||
|
Vol. {book.volume}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reading status + actions */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${statusClassName}`}>
|
||||||
|
{statusLabel}
|
||||||
|
{book.reading_status === "reading" && book.reading_current_page != null && ` · p. ${book.reading_current_page}`}
|
||||||
|
</span>
|
||||||
|
{book.reading_last_read_at && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(book.reading_last_read_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||||
|
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata pills */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold border ${formatColor}`}>
|
||||||
|
{formatBadge}
|
||||||
|
</span>
|
||||||
|
{book.page_count && (
|
||||||
|
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||||
|
{book.page_count} {t("dashboard.pages").toLowerCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.language && (
|
||||||
|
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||||
|
{book.language.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.isbn && (
|
||||||
|
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-mono font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||||
|
ISBN {book.isbn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{book.publish_date && (
|
||||||
|
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||||
|
{book.publish_date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{book.summary && (
|
||||||
|
<SafeHtml html={book.summary} className="text-sm text-muted-foreground leading-relaxed" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.page_count && book.page_count > 0 && (
|
{/* Technical info (collapsible) */}
|
||||||
<div className="mt-8">
|
<details className="group">
|
||||||
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors select-none flex items-center gap-1.5">
|
||||||
|
<svg className="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{t("bookDetail.technicalInfo")}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 p-4 rounded-lg bg-muted/30 border border-border/50 space-y-2 text-xs">
|
||||||
|
{book.file_path && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground">{t("bookDetail.file")}</span>
|
||||||
|
<code className="font-mono text-foreground break-all">{book.file_path}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{book.file_format && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">{t("bookDetail.fileFormat")}</span>
|
||||||
|
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{book.file_parse_status && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">{t("bookDetail.parsing")}</span>
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
book.file_parse_status === "success" ? "bg-success/10 text-success" :
|
||||||
|
book.file_parse_status === "failed" ? "bg-destructive/10 text-destructive" :
|
||||||
|
"bg-muted/50 text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{book.file_parse_status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Book ID</span>
|
||||||
|
<code className="font-mono text-foreground">{book.id}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Library ID</span>
|
||||||
|
<code className="font-mono text-foreground">{book.library_id}</code>
|
||||||
|
</div>
|
||||||
|
{book.updated_at && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">{t("bookDetail.updatedAt")}</span>
|
||||||
|
<span className="text-foreground">{new Date(book.updated_at).toLocaleString(locale)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Book Preview */}
|
||||||
|
{book.page_count && book.page_count > 0 && (
|
||||||
|
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LiveSearchForm } from "../components/LiveSearchForm";
|
|||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,10 +13,13 @@ export default async function BooksPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
|
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
||||||
|
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
@@ -53,11 +57,14 @@ export default async function BooksPage({
|
|||||||
reading_status: "unread" as const,
|
reading_status: "unread" as const,
|
||||||
reading_current_page: null,
|
reading_current_page: null,
|
||||||
reading_last_read_at: null,
|
reading_last_read_at: null,
|
||||||
|
summary: null,
|
||||||
|
isbn: null,
|
||||||
|
publish_date: null,
|
||||||
}));
|
}));
|
||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
|
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -75,23 +82,37 @@ export default async function BooksPage({
|
|||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
const libraryOptions = [
|
const libraryOptions = [
|
||||||
{ value: "", label: "All libraries" },
|
{ value: "", label: t("books.allLibraries") },
|
||||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: "", label: "All" },
|
{ value: "", label: t("common.all") },
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "unread", label: t("status.unread") },
|
||||||
{ value: "reading", label: "In progress" },
|
{ value: "reading", label: t("status.reading") },
|
||||||
{ value: "read", label: "Read" },
|
{ value: "read", label: t("status.read") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatOptions = [
|
||||||
|
{ value: "", label: t("books.allFormats") },
|
||||||
|
{ value: "cbz", label: "CBZ" },
|
||||||
|
{ value: "cbr", label: "CBR" },
|
||||||
|
{ value: "pdf", label: "PDF" },
|
||||||
|
{ value: "epub", label: "EPUB" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const metadataOptions = [
|
||||||
|
{ value: "", label: t("series.metadataAll") },
|
||||||
|
{ value: "linked", label: t("series.metadataLinked") },
|
||||||
|
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: "", label: "Title" },
|
{ value: "", label: t("books.sortTitle") },
|
||||||
{ value: "latest", label: "Latest added" },
|
{ value: "latest", label: t("books.sortLatest") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -100,7 +121,7 @@ export default async function BooksPage({
|
|||||||
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
Books
|
{t("books.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,10 +130,12 @@ export default async function BooksPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/books"
|
basePath="/books"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||||
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
|
||||||
|
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||||
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -121,18 +144,18 @@ export default async function BooksPage({
|
|||||||
{/* Résultats */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null ? (
|
{searchQuery && totalHits !== null ? (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
|
||||||
</p>
|
</p>
|
||||||
) : !searchQuery && (
|
) : !searchQuery && (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{total} book{total !== 1 ? 's' : ''}
|
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Séries matchantes */}
|
{/* Séries matchantes */}
|
||||||
{seriesHits.length > 0 && (
|
{seriesHits.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-3">Series</h2>
|
<h2 className="text-lg font-semibold text-foreground mb-3">{t("books.seriesHeading")}</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{seriesHits.map((s) => (
|
{seriesHits.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -144,18 +167,18 @@ export default async function BooksPage({
|
|||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
{t("books.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +191,7 @@ export default async function BooksPage({
|
|||||||
{/* Grille de livres */}
|
{/* Grille de livres */}
|
||||||
{displayBooks.length > 0 ? (
|
{displayBooks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Books</h2>}
|
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
||||||
<BooksGrid books={displayBooks} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
@@ -181,7 +204,7 @@ export default async function BooksPage({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={searchQuery ? `No books found for "${searchQuery}"` : "No books available"} />
|
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
|
const readingStatusOverlayClasses: Record<ReadingStatus, string | null> = {
|
||||||
unread: null,
|
unread: null,
|
||||||
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
|
reading: "bg-amber-500/90 text-white",
|
||||||
read: { label: "Lu", className: "bg-green-600/90 text-white" },
|
read: "bg-green-600/90 text-white",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BookCardProps {
|
interface BookCardProps {
|
||||||
@@ -16,7 +17,7 @@ interface BookCardProps {
|
|||||||
readingStatus?: ReadingStatus;
|
readingStatus?: ReadingStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
const BookImage = memo(function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
@@ -50,16 +51,21 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
onError={() => setHasError(true)}
|
onError={() => setHasError(true)}
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||||
const status = readingStatus ?? book.reading_status;
|
const status = readingStatus ?? book.reading_status;
|
||||||
const overlay = status ? readingStatusOverlay[status] : null;
|
const overlayClass = status ? readingStatusOverlayClasses[status] : null;
|
||||||
|
const statusLabels: Record<ReadingStatus, string> = {
|
||||||
|
unread: t("status.unread"),
|
||||||
|
reading: t("status.reading"),
|
||||||
|
read: t("status.read"),
|
||||||
|
};
|
||||||
|
|
||||||
const isRead = status === "read";
|
const isRead = status === "read";
|
||||||
|
|
||||||
@@ -71,11 +77,11 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<BookImage
|
<BookImage
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={t("books.coverOf", { name: book.title })}
|
||||||
/>
|
/>
|
||||||
{overlay && (
|
{overlayClass && status && (
|
||||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
|
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlayClass}`}>
|
||||||
{overlay.label}
|
{statusLabels[status]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +114,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
|
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||||
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||||
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||||
|
${(book.format ?? book.kind) === 'epub' ? 'bg-info/10 text-info' : ''}
|
||||||
`}>
|
`}>
|
||||||
{book.format ?? book.kind}
|
{book.format ?? book.kind}
|
||||||
</span>
|
</span>
|
||||||
@@ -121,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface BooksGridProps {
|
interface BooksGridProps {
|
||||||
books: (BookDto & { coverUrl?: string })[];
|
books: (BookDto & { coverUrl?: string })[];
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount: number }) {
|
export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
const pages = Array.from({ length: PAGE_SIZE }, (_, i) => offset + i + 1).filter(
|
const pages = Array.from({ length: PAGE_SIZE }, (_, i) => offset + i + 1).filter(
|
||||||
@@ -16,9 +18,9 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
|||||||
<div className="bg-card rounded-xl border border-border p-6">
|
<div className="bg-card rounded-xl border border-border p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Preview
|
{t("bookPreview.preview")}
|
||||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
pages {offset + 1}–{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
|
{t("bookPreview.pages", { start: offset + 1, end: Math.min(offset + PAGE_SIZE, pageCount), total: pageCount })}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -27,14 +29,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
|||||||
disabled={offset === 0}
|
disabled={offset === 0}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
← Prev
|
{t("bookPreview.prev")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
||||||
disabled={offset + PAGE_SIZE >= pageCount}
|
disabled={offset + PAGE_SIZE >= pageCount}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Next →
|
{t("bookPreview.next")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface ConvertButtonProps {
|
interface ConvertButtonProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
@@ -15,6 +16,7 @@ type ConvertState =
|
|||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
export function ConvertButton({ bookId }: ConvertButtonProps) {
|
export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<ConvertState>({ type: "idle" });
|
const [state, setState] = useState<ConvertState>({ type: "idle" });
|
||||||
|
|
||||||
const handleConvert = async () => {
|
const handleConvert = async () => {
|
||||||
@@ -23,22 +25,22 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
setState({ type: "error", message: body.error || "Conversion failed" });
|
setState({ type: "error", message: body.error || t("convert.failed") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await res.json();
|
const job = await res.json();
|
||||||
setState({ type: "success", jobId: job.id });
|
setState({ type: "success", jobId: job.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ type: "error", message: err instanceof Error ? err.message : "Unknown error" });
|
setState({ type: "error", message: err instanceof Error ? err.message : t("convert.unknownError") });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.type === "success") {
|
if (state.type === "success") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-success">
|
<div className="flex items-center gap-2 text-sm text-success">
|
||||||
<span>Conversion started.</span>
|
<span>{t("convert.started")}</span>
|
||||||
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
|
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
|
||||||
View job →
|
{t("convert.viewJob")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -52,7 +54,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
className="text-xs text-muted-foreground hover:underline text-left"
|
className="text-xs text-muted-foreground hover:underline text-left"
|
||||||
onClick={() => setState({ type: "idle" })}
|
onClick={() => setState({ type: "idle" })}
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -65,7 +67,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
onClick={handleConvert}
|
onClick={handleConvert}
|
||||||
disabled={state.type === "loading"}
|
disabled={state.type === "loading"}
|
||||||
>
|
>
|
||||||
{state.type === "loading" ? "Converting…" : "Convert to CBZ"}
|
{state.type === "loading" ? t("convert.converting") : t("convert.convertToCbz")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
AreaChart, Area, Line, LineChart,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Donut
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcDonutChart({
|
||||||
|
data,
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: { name: string; value: number; color: string }[];
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ResponsiveContainer width={130} height={130}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={32}
|
||||||
|
outerRadius={55}
|
||||||
|
dataKey="value"
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<Cell key={i} fill={d.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => value}
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||||
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bar chart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcBarChart({
|
||||||
|
data,
|
||||||
|
color = "hsl(198 78% 37%)",
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: { label: string; value: number }[];
|
||||||
|
color?: string;
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Area / Line chart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcAreaChart({
|
||||||
|
data,
|
||||||
|
color = "hsl(142 60% 45%)",
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: { label: string; value: number }[];
|
||||||
|
color?: string;
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill="url(#areaGradient)" dot={{ r: 3, fill: color }} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Horizontal stacked bar (libraries breakdown)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcStackedBar({
|
||||||
|
data,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
data: { name: string; read: number; reading: number; unread: number; sizeLabel: string }[];
|
||||||
|
labels: { read: string; reading: string; unread: string; books: string };
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={data.length * 60 + 30}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
formatter={(value: string) => <span className="text-muted-foreground">{value}</span>}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="read" stackId="a" fill="hsl(142 60% 45%)" name={labels.read} radius={[0, 0, 0, 0]} />
|
||||||
|
<Bar dataKey="reading" stackId="a" fill="hsl(45 93% 47%)" name={labels.reading} />
|
||||||
|
<Bar dataKey="unread" stackId="a" fill="hsl(220 13% 70%)" name={labels.unread} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Horizontal bar chart (top series)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcHorizontalBar({
|
||||||
|
data,
|
||||||
|
color = "hsl(142 60% 45%)",
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: { name: string; value: number; subLabel: string }[];
|
||||||
|
color?: string;
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-4">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={data.length * 40 + 10}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill={color} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-line chart (jobs over time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcMultiLineChart({
|
||||||
|
data,
|
||||||
|
lines,
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
lines: { key: string; label: string; color: string }[];
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
|
||||||
|
if (data.length === 0 || !hasData)
|
||||||
|
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{lines.map((l) => (
|
||||||
|
<Line
|
||||||
|
key={l.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={l.key}
|
||||||
|
name={l.label}
|
||||||
|
stroke={l.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: l.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,49 @@ import { createPortal } from "react-dom";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { BookDto } from "@/lib/api";
|
import { BookDto } from "@/lib/api";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
function LockButton({
|
||||||
|
locked,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
locked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`p-1 rounded transition-colors ${
|
||||||
|
locked
|
||||||
|
? "text-amber-500 hover:text-amber-600"
|
||||||
|
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||||
|
>
|
||||||
|
{locked ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface EditBookFormProps {
|
interface EditBookFormProps {
|
||||||
book: BookDto;
|
book: BookDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditBookForm({ book }: EditBookFormProps) {
|
export function EditBookForm({ book }: EditBookFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -23,6 +60,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
const [series, setSeries] = useState(book.series ?? "");
|
const [series, setSeries] = useState(book.series ?? "");
|
||||||
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
|
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
|
||||||
const [language, setLanguage] = useState(book.language ?? "");
|
const [language, setLanguage] = useState(book.language ?? "");
|
||||||
|
const [summary, setSummary] = useState(book.summary ?? "");
|
||||||
|
const [isbn, setIsbn] = useState(book.isbn ?? "");
|
||||||
|
const [publishDate, setPublishDate] = useState(book.publish_date ?? "");
|
||||||
|
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(book.locked_fields ?? {});
|
||||||
|
|
||||||
|
const toggleLock = (field: string) => {
|
||||||
|
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||||
|
};
|
||||||
|
|
||||||
const addAuthor = () => {
|
const addAuthor = () => {
|
||||||
const v = authorInput.trim();
|
const v = authorInput.trim();
|
||||||
@@ -51,6 +96,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
setSeries(book.series ?? "");
|
setSeries(book.series ?? "");
|
||||||
setVolume(book.volume?.toString() ?? "");
|
setVolume(book.volume?.toString() ?? "");
|
||||||
setLanguage(book.language ?? "");
|
setLanguage(book.language ?? "");
|
||||||
|
setSummary(book.summary ?? "");
|
||||||
|
setIsbn(book.isbn ?? "");
|
||||||
|
setPublishDate(book.publish_date ?? "");
|
||||||
|
setLockedFields(book.locked_fields ?? {});
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [book]);
|
}, [book]);
|
||||||
@@ -85,17 +134,21 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
series: series.trim() || null,
|
series: series.trim() || null,
|
||||||
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
|
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
|
||||||
language: language.trim() || null,
|
language: language.trim() || null,
|
||||||
|
summary: summary.trim() || null,
|
||||||
|
isbn: isbn.trim() || null,
|
||||||
|
publish_date: publishDate.trim() || null,
|
||||||
|
locked_fields: lockedFields,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
setError(data.error ?? t("editBook.saveError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Erreur réseau");
|
setError(t("common.networkError"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -113,7 +166,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||||
<h3 className="font-semibold text-foreground">Modifier les métadonnées</h3>
|
<h3 className="font-semibold text-foreground">{t("editBook.editMetadata")}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -130,18 +183,24 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel required>Titre</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel required>{t("editBook.title")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Titre du livre"
|
placeholder={t("editBook.titlePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* Auteurs — multi-valeur */}
|
{/* Auteurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Auteur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.authors")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{authors.length > 0 && (
|
{authors.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -156,7 +215,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
onClick={() => removeAuthor(i)}
|
onClick={() => removeAuthor(i)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="hover:text-destructive transition-colors ml-0.5"
|
className="hover:text-destructive transition-colors ml-0.5"
|
||||||
aria-label={`Supprimer ${a}`}
|
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -171,7 +230,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
onChange={(e) => setAuthorInput(e.target.value)}
|
onChange={(e) => setAuthorInput(e.target.value)}
|
||||||
onKeyDown={handleAuthorKeyDown}
|
onKeyDown={handleAuthorKeyDown}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Ajouter un auteur (Entrée pour valider)"
|
placeholder={t("editBook.addAuthor")}
|
||||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -187,38 +246,98 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Langue</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.language")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="ex : fr, en, jp"
|
placeholder={t("editBook.languagePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Série</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.series")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={series}
|
value={series}
|
||||||
onChange={(e) => setSeries(e.target.value)}
|
onChange={(e) => setSeries(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Nom de la série"
|
placeholder={t("editBook.seriesPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Volume</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.volume")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => setVolume(e.target.value)}
|
onChange={(e) => setVolume(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Numéro de volume"
|
placeholder={t("editBook.volumePlaceholder")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.isbn")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<FormInput
|
||||||
|
value={isbn}
|
||||||
|
onChange={(e) => setIsbn(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="ISBN"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.publishDate")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<FormInput
|
||||||
|
value={publishDate}
|
||||||
|
onChange={(e) => setPublishDate(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder={t("editBook.publishDatePlaceholder")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField className="sm:col-span-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder={t("editBook.descriptionPlaceholder")}
|
||||||
|
rows={4}
|
||||||
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lock legend */}
|
||||||
|
{Object.values(lockedFields).some(Boolean) && (
|
||||||
|
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||||
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
{t("editBook.lockedFieldsNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
@@ -231,14 +350,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Annuler
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !title.trim()}
|
disabled={isPending || !title.trim()}
|
||||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
{isPending ? t("editBook.savingLabel") : t("editBook.saveLabel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -254,7 +373,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||||
>
|
>
|
||||||
<span>✏️</span> Modifier
|
<span>✏️</span> {t("editBook.editMetadata")}
|
||||||
</button>
|
</button>
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,44 @@ import { useState, useTransition, useEffect, useCallback } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
function LockButton({
|
||||||
|
locked,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
locked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`p-1 rounded transition-colors ${
|
||||||
|
locked
|
||||||
|
? "text-amber-500 hover:text-amber-600"
|
||||||
|
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||||
|
>
|
||||||
|
{locked ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES_STATUS_VALUES = ["", "ongoing", "ended", "hiatus", "cancelled", "upcoming"] as const;
|
||||||
|
|
||||||
interface EditSeriesFormProps {
|
interface EditSeriesFormProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -14,6 +52,9 @@ interface EditSeriesFormProps {
|
|||||||
currentBookLanguage: string | null;
|
currentBookLanguage: string | null;
|
||||||
currentDescription: string | null;
|
currentDescription: string | null;
|
||||||
currentStartYear: number | null;
|
currentStartYear: number | null;
|
||||||
|
currentTotalVolumes: number | null;
|
||||||
|
currentStatus: string | null;
|
||||||
|
currentLockedFields: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditSeriesForm({
|
export function EditSeriesForm({
|
||||||
@@ -25,7 +66,11 @@ export function EditSeriesForm({
|
|||||||
currentBookLanguage,
|
currentBookLanguage,
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentStartYear,
|
currentStartYear,
|
||||||
|
currentTotalVolumes,
|
||||||
|
currentStatus,
|
||||||
|
currentLockedFields,
|
||||||
}: EditSeriesFormProps) {
|
}: EditSeriesFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -41,12 +86,21 @@ export function EditSeriesForm({
|
|||||||
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
||||||
const [description, setDescription] = useState(currentDescription ?? "");
|
const [description, setDescription] = useState(currentDescription ?? "");
|
||||||
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
||||||
|
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
|
||||||
|
const [status, setStatus] = useState(currentStatus ?? "");
|
||||||
|
|
||||||
|
// Lock states
|
||||||
|
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
|
||||||
|
|
||||||
// Propagation aux livres — opt-in via bouton
|
// Propagation aux livres — opt-in via bouton
|
||||||
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
||||||
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
||||||
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
||||||
|
|
||||||
|
const toggleLock = (field: string) => {
|
||||||
|
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||||
|
};
|
||||||
|
|
||||||
const addAuthor = () => {
|
const addAuthor = () => {
|
||||||
const v = authorInput.trim();
|
const v = authorInput.trim();
|
||||||
if (v && !authors.includes(v)) {
|
if (v && !authors.includes(v)) {
|
||||||
@@ -95,12 +149,15 @@ export function EditSeriesForm({
|
|||||||
setPublisherInput("");
|
setPublisherInput("");
|
||||||
setDescription(currentDescription ?? "");
|
setDescription(currentDescription ?? "");
|
||||||
setStartYear(currentStartYear?.toString() ?? "");
|
setStartYear(currentStartYear?.toString() ?? "");
|
||||||
|
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
|
||||||
|
setStatus(currentStatus ?? "");
|
||||||
|
setLockedFields(currentLockedFields);
|
||||||
setShowApplyToBooks(false);
|
setShowApplyToBooks(false);
|
||||||
setBookAuthor(currentBookAuthor ?? "");
|
setBookAuthor(currentBookAuthor ?? "");
|
||||||
setBookLanguage(currentBookLanguage ?? "");
|
setBookLanguage(currentBookLanguage ?? "");
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentBookAuthor, currentBookLanguage]);
|
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -133,6 +190,9 @@ export function EditSeriesForm({
|
|||||||
publishers: finalPublishers,
|
publishers: finalPublishers,
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
||||||
|
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
|
||||||
|
status: status || null,
|
||||||
|
locked_fields: lockedFields,
|
||||||
};
|
};
|
||||||
if (showApplyToBooks) {
|
if (showApplyToBooks) {
|
||||||
body.author = bookAuthor.trim() || null;
|
body.author = bookAuthor.trim() || null;
|
||||||
@@ -149,7 +209,7 @@ export function EditSeriesForm({
|
|||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
setError(data.error ?? t("editBook.saveError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -160,7 +220,7 @@ export function EditSeriesForm({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Erreur réseau");
|
setError(t("common.networkError"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -178,7 +238,7 @@ export function EditSeriesForm({
|
|||||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||||
<h3 className="font-semibold text-foreground">Modifier la série</h3>
|
<h3 className="font-semibold text-foreground">{t("editSeries.title")}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -195,17 +255,20 @@ export function EditSeriesForm({
|
|||||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel required>Nom</FormLabel>
|
<FormLabel required>{t("editSeries.name")}</FormLabel>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Nom de la série"
|
placeholder={t("editSeries.namePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Année de début</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editSeries.startYear")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1900"
|
min="1900"
|
||||||
@@ -213,13 +276,50 @@ export function EditSeriesForm({
|
|||||||
value={startYear}
|
value={startYear}
|
||||||
onChange={(e) => setStartYear(e.target.value)}
|
onChange={(e) => setStartYear(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="ex : 1990"
|
placeholder={t("editSeries.startYearPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editSeries.totalVolumes")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={totalVolumes}
|
||||||
|
onChange={(e) => setTotalVolumes(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="12"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editSeries.status")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||||
|
>
|
||||||
|
{SERIES_STATUS_VALUES.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v === "" ? t("seriesStatus.notDefined") : t(`seriesStatus.${v}` as any)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
{/* Auteurs — multi-valeur */}
|
{/* Auteurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Auteur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editSeries.authors")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{authors.length > 0 && (
|
{authors.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -234,7 +334,7 @@ export function EditSeriesForm({
|
|||||||
onClick={() => removeAuthor(i)}
|
onClick={() => removeAuthor(i)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="hover:text-destructive transition-colors ml-0.5"
|
className="hover:text-destructive transition-colors ml-0.5"
|
||||||
aria-label={`Supprimer ${a}`}
|
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -249,7 +349,7 @@ export function EditSeriesForm({
|
|||||||
onChange={(e) => setAuthorInput(e.target.value)}
|
onChange={(e) => setAuthorInput(e.target.value)}
|
||||||
onKeyDown={handleAuthorKeyDown}
|
onKeyDown={handleAuthorKeyDown}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Ajouter un auteur (Entrée pour valider)"
|
placeholder={t("editBook.addAuthor")}
|
||||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -269,9 +369,9 @@ export function EditSeriesForm({
|
|||||||
? "border-primary bg-primary/10 text-primary"
|
? "border-primary bg-primary/10 text-primary"
|
||||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
title="Appliquer auteur et langue à tous les livres de la série"
|
title={t("editSeries.applyToBooksTitle")}
|
||||||
>
|
>
|
||||||
→ livres
|
{t("editSeries.applyToBooks")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,21 +380,21 @@ export function EditSeriesForm({
|
|||||||
{showApplyToBooks && (
|
{showApplyToBooks && (
|
||||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Auteur (livres)</FormLabel>
|
<FormLabel>{t("editSeries.bookAuthor")}</FormLabel>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={bookAuthor}
|
value={bookAuthor}
|
||||||
onChange={(e) => setBookAuthor(e.target.value)}
|
onChange={(e) => setBookAuthor(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Écrase le champ auteur de chaque livre"
|
placeholder={t("editSeries.bookAuthorPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel>Langue (livres)</FormLabel>
|
<FormLabel>{t("editSeries.bookLanguage")}</FormLabel>
|
||||||
<FormInput
|
<FormInput
|
||||||
value={bookLanguage}
|
value={bookLanguage}
|
||||||
onChange={(e) => setBookLanguage(e.target.value)}
|
onChange={(e) => setBookLanguage(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="ex : fr, en, jp"
|
placeholder={t("editBook.languagePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +402,10 @@ export function EditSeriesForm({
|
|||||||
|
|
||||||
{/* Éditeurs — multi-valeur */}
|
{/* Éditeurs — multi-valeur */}
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Éditeur(s)</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editSeries.publishers")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{publishers.length > 0 && (
|
{publishers.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -317,7 +420,7 @@ export function EditSeriesForm({
|
|||||||
onClick={() => removePublisher(i)}
|
onClick={() => removePublisher(i)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="hover:text-destructive transition-colors ml-0.5"
|
className="hover:text-destructive transition-colors ml-0.5"
|
||||||
aria-label={`Supprimer ${p}`}
|
aria-label={t("editBook.removeAuthor", { name: p })}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -332,7 +435,7 @@ export function EditSeriesForm({
|
|||||||
onChange={(e) => setPublisherInput(e.target.value)}
|
onChange={(e) => setPublisherInput(e.target.value)}
|
||||||
onKeyDown={handlePublisherKeyDown}
|
onKeyDown={handlePublisherKeyDown}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Ajouter un éditeur (Entrée pour valider)"
|
placeholder={t("editSeries.addPublisher")}
|
||||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -348,18 +451,31 @@ export function EditSeriesForm({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<FormLabel>Description</FormLabel>
|
<div className="flex items-center gap-1">
|
||||||
|
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||||
|
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Synopsis ou description de la série…"
|
placeholder={t("editSeries.descriptionPlaceholder")}
|
||||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lock legend */}
|
||||||
|
{Object.values(lockedFields).some(Boolean) && (
|
||||||
|
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||||
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
{t("editBook.lockedFieldsNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -370,14 +486,14 @@ export function EditSeriesForm({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Annuler
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
{isPending ? t("common.saving") : t("common.save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -393,7 +509,7 @@ export function EditSeriesForm({
|
|||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||||
>
|
>
|
||||||
<span>✏️</span> Modifier la série
|
<span>✏️</span> {t("editSeries.title")}
|
||||||
</button>
|
</button>
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface TreeNode extends FolderItem {
|
interface TreeNode extends FolderItem {
|
||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
@@ -15,6 +16,7 @@ interface FolderBrowserProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
|
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Convert initial folders to tree structure
|
// Convert initial folders to tree structure
|
||||||
const [tree, setTree] = useState<TreeNode[]>(
|
const [tree, setTree] = useState<TreeNode[]>(
|
||||||
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
|
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
|
||||||
@@ -173,7 +175,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
|||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{tree.length === 0 ? (
|
{tree.length === 0 ? (
|
||||||
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
||||||
No folders found
|
{t("folder.noFolders")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tree.map(node => renderNode(node))
|
tree.map(node => renderNode(node))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { FolderBrowser } from "./FolderBrowser";
|
import { FolderBrowser } from "./FolderBrowser";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface FolderPickerProps {
|
interface FolderPickerProps {
|
||||||
initialFolders: FolderItem[];
|
initialFolders: FolderItem[];
|
||||||
@@ -13,6 +15,7 @@ interface FolderPickerProps {
|
|||||||
|
|
||||||
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
|
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleSelect = (path: string) => {
|
const handleSelect = (path: string) => {
|
||||||
onSelect(path);
|
onSelect(path);
|
||||||
@@ -27,7 +30,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={selectedPath || "Select a folder..."}
|
value={selectedPath || t("folder.selectFolder")}
|
||||||
className={`
|
className={`
|
||||||
w-full px-3 py-2 rounded-lg border bg-card
|
w-full px-3 py-2 rounded-lg border bg-card
|
||||||
text-sm font-mono
|
text-sm font-mono
|
||||||
@@ -57,12 +60,12 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Browse
|
{t("common.browse")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Popup Modal */}
|
{/* Popup Modal */}
|
||||||
{isOpen && (
|
{isOpen && createPortal(
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
@@ -79,7 +82,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">Select Folder</span>
|
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -104,7 +107,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Click a folder to select it
|
{t("folder.clickToSelect")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -113,13 +116,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface JobDetailLiveProps {
|
||||||
|
jobId: string;
|
||||||
|
isTerminal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobDetailLive({ jobId, isTerminal }: JobDetailLiveProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isTerminalRef = useRef(isTerminal);
|
||||||
|
isTerminalRef.current = isTerminal;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTerminalRef.current) return;
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [jobId, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||||
|
|
||||||
interface ProgressEvent {
|
interface ProgressEvent {
|
||||||
@@ -24,6 +25,7 @@ interface JobProgressProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
@@ -53,25 +55,25 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
onComplete?.();
|
onComplete?.();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to parse SSE data");
|
setError(t("jobProgress.sseError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (err) => {
|
eventSource.onerror = (err) => {
|
||||||
console.error("SSE error:", err);
|
console.error("SSE error:", err);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setError("Connection lost");
|
setError(t("jobProgress.connectionLost"));
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [jobId, onComplete]);
|
}, [jobId, onComplete, t]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||||
Error: {error}
|
{t("jobProgress.error", { message: error })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
if (!progress) {
|
if (!progress) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-muted-foreground text-sm">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
Loading progress...
|
{t("jobProgress.loadingProgress")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,14 +90,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const processed = progress.processed_files ?? 0;
|
const processed = progress.processed_files ?? 0;
|
||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
||||||
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "thumbnails" : "files";
|
const unitLabel = progress.status === "extracting_pages" ? t("jobProgress.pages") : progress.status === "generating_thumbnails" ? t("jobProgress.thumbnails") : t("jobProgress.filesUnit");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-card rounded-lg border border-border">
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<StatusBadge status={progress.status} />
|
<StatusBadge status={progress.status} />
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<Badge variant="success">Complete</Badge>
|
<Badge variant="success">{t("jobProgress.done")}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,20 +107,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
<span>{processed} / {total} {unitLabel}</span>
|
<span>{processed} / {total} {unitLabel}</span>
|
||||||
{progress.current_file && (
|
{progress.current_file && (
|
||||||
<span className="truncate max-w-md" title={progress.current_file}>
|
<span className="truncate max-w-md" title={progress.current_file}>
|
||||||
Current: {progress.current_file.length > 40
|
{t("jobProgress.currentFile", { file: progress.current_file.length > 40
|
||||||
? progress.current_file.substring(0, 40) + "..."
|
? progress.current_file.substring(0, 40) + "..."
|
||||||
: progress.current_file}
|
: progress.current_file })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress.stats_json && !isPhase2 && (
|
{progress.stats_json && !isPhase2 && (
|
||||||
<div className="flex flex-wrap gap-3 text-xs">
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
<Badge variant="primary">{t("jobProgress.scanned", { count: progress.stats_json.scanned_files })}</Badge>
|
||||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
<Badge variant="success">{t("jobProgress.indexed", { count: progress.stats_json.indexed_files })}</Badge>
|
||||||
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
<Badge variant="warning">{t("jobProgress.removed", { count: progress.stats_json.removed_files })}</Badge>
|
||||||
{progress.stats_json.errors > 0 && (
|
{progress.stats_json.errors > 0 && (
|
||||||
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
|
<Badge variant="error">{t("jobProgress.errors", { count: progress.stats_json.errors })}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
|
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -33,6 +34,7 @@ interface JobRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||||
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||||
|
|
||||||
@@ -57,28 +59,11 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
||||||
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
|
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
|
||||||
|
|
||||||
// Files column: index-phase stats only (Phase 1 discovery)
|
const isMetadataBatch = job.type === "metadata_batch";
|
||||||
const filesDisplay =
|
const isMetadataRefresh = job.type === "metadata_refresh";
|
||||||
job.status === "running" && !isPhase2
|
|
||||||
? job.total_files != null
|
|
||||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
|
||||||
: scanned > 0
|
|
||||||
? `${scanned} scanned`
|
|
||||||
: "-"
|
|
||||||
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
|
||||||
? null // rendered below as ✓ / − / ⚠
|
|
||||||
: scanned > 0
|
|
||||||
? `${scanned} scanned`
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
|
// Thumbnails progress (Phase 2: extracting_pages + generating_thumbnails)
|
||||||
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
|
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
|
||||||
const thumbDisplay =
|
|
||||||
thumbInProgress && job.total_files != null
|
|
||||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
|
||||||
: job.status === "success" && job.total_files != null && hasThumbnailPhase
|
|
||||||
? `✓ ${job.total_files}`
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -113,32 +98,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||||
onClick={() => setShowProgress(!showProgress)}
|
onClick={() => setShowProgress(!showProgress)}
|
||||||
>
|
>
|
||||||
{showProgress ? "Hide" : "Show"} progress
|
{showProgress ? t("jobRow.hideProgress") : t("jobRow.showProgress")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{filesDisplay !== null ? (
|
{/* Running progress */}
|
||||||
<span className="text-sm text-foreground">{filesDisplay}</span>
|
{isActive && job.total_files != null && (
|
||||||
) : (
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<span className="text-sm text-foreground">{job.processed_files ?? 0}/{job.total_files}</span>
|
||||||
<span className="text-success">✓ {indexed}</span>
|
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||||
{removed > 0 && <span className="text-warning">− {removed}</span>}
|
|
||||||
{errors > 0 && <span className="text-error">⚠ {errors}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{job.status === "running" && !isPhase2 && job.total_files != null && (
|
{/* Completed stats with icons */}
|
||||||
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
{!isActive && (
|
||||||
)}
|
<div className="flex items-center gap-3 text-xs">
|
||||||
</div>
|
{/* Files: indexed count */}
|
||||||
</td>
|
{indexed > 0 && (
|
||||||
<td className="px-4 py-3">
|
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
||||||
<div className="flex flex-col gap-1">
|
<Icon name="document" size="sm" />
|
||||||
<span className="text-sm text-foreground">{thumbDisplay}</span>
|
{indexed}
|
||||||
{thumbInProgress && job.total_files != null && (
|
</span>
|
||||||
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
)}
|
||||||
|
{/* Removed files */}
|
||||||
|
{removed > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
{removed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Thumbnails */}
|
||||||
|
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||||
|
<Icon name="image" size="sm" />
|
||||||
|
{job.total_files}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Metadata batch: series processed */}
|
||||||
|
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||||
|
<Icon name="tag" size="sm" />
|
||||||
|
{job.total_files}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Metadata refresh: links refreshed */}
|
||||||
|
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
||||||
|
<Icon name="tag" size="sm" />
|
||||||
|
{job.total_files}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Errors */}
|
||||||
|
{errors > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
{errors}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Scanned only (no other stats) */}
|
||||||
|
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
||||||
|
)}
|
||||||
|
{/* Nothing to show */}
|
||||||
|
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||||
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -154,7 +181,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
View
|
{t("jobRow.view")}
|
||||||
</Link>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||||
<Button
|
<Button
|
||||||
@@ -162,7 +189,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCancel(job.id)}
|
onClick={() => onCancel(job.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +197,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
</tr>
|
</tr>
|
||||||
{showProgress && isActive && (
|
{showProgress && isActive && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-3 bg-muted/50">
|
<td colSpan={8} className="px-4 py-3 bg-muted/50">
|
||||||
<JobProgress
|
<JobProgress
|
||||||
jobId={job.id}
|
jobId={job.id}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { ProgressBar } from "./ui/ProgressBar";
|
import { ProgressBar } from "./ui/ProgressBar";
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ const ChevronIcon = ({ className }: { className?: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function JobsIndicator() {
|
export function JobsIndicator() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -52,21 +54,62 @@ export function JobsIndicator() {
|
|||||||
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchActiveJobs = async () => {
|
let eventSource: EventSource | null = null;
|
||||||
try {
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const response = await fetch("/api/jobs/active");
|
|
||||||
if (response.ok) {
|
const connect = () => {
|
||||||
const jobs = await response.json();
|
if (eventSource) {
|
||||||
setActiveJobs(jobs);
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource = new EventSource("/api/jobs/stream");
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const allJobs: Job[] = JSON.parse(event.data);
|
||||||
|
const active = allJobs.filter(j =>
|
||||||
|
j.status === "running" || j.status === "pending" ||
|
||||||
|
j.status === "extracting_pages" || j.status === "generating_thumbnails"
|
||||||
|
);
|
||||||
|
setActiveJobs(active);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed data
|
||||||
}
|
}
|
||||||
} catch (error) {
|
};
|
||||||
console.error("Failed to fetch jobs:", error);
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
// Reconnect after 5s on error
|
||||||
|
reconnectTimeout = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchActiveJobs();
|
const handleVisibilityChange = () => {
|
||||||
const interval = setInterval(fetchActiveJobs, 2000);
|
if (document.hidden) {
|
||||||
return () => clearInterval(interval);
|
disconnect();
|
||||||
|
} else {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Position the popin relative to the button
|
// Position the popin relative to the button
|
||||||
@@ -152,7 +195,7 @@ export function JobsIndicator() {
|
|||||||
hover:bg-accent
|
hover:bg-accent
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
"
|
"
|
||||||
title="View all jobs"
|
title={t("jobsIndicator.viewAll")}
|
||||||
>
|
>
|
||||||
<JobsIcon className="w-[18px] h-[18px]" />
|
<JobsIcon className="w-[18px] h-[18px]" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -187,11 +230,11 @@ export function JobsIndicator() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">📊</span>
|
<span className="text-xl">📊</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
<h3 className="font-semibold text-foreground">{t("jobsIndicator.activeTasks")}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningJobs.length > 0
|
{runningJobs.length > 0
|
||||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
? t("jobsIndicator.runningAndPending", { running: runningJobs.length, pending: pendingJobs.length })
|
||||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
: t("jobsIndicator.pendingTasks", { count: pendingJobs.length, plural: pendingJobs.length !== 1 ? "s" : "" })
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +244,7 @@ export function JobsIndicator() {
|
|||||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
View All →
|
{t("jobsIndicator.viewAllLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +252,7 @@ export function JobsIndicator() {
|
|||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="px-4 py-3 border-b border-border/60">
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span className="text-muted-foreground">Overall Progress</span>
|
<span className="text-muted-foreground">{t("jobsIndicator.overallProgress")}</span>
|
||||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||||
@@ -221,7 +264,7 @@ export function JobsIndicator() {
|
|||||||
{activeJobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
<span className="text-4xl mb-2">✅</span>
|
<span className="text-4xl mb-2">✅</span>
|
||||||
<p>No active jobs</p>
|
<p>{t("jobsIndicator.noActiveTasks")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
@@ -242,7 +285,7 @@ export function JobsIndicator() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||||
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
|
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
|
||||||
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
|
{t(`jobType.${job.type}` as any) !== `jobType.${job.type}` ? t(`jobType.${job.type}` as any) : job.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,7 +324,7 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
||||||
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
|
<p className="text-xs text-muted-foreground text-center">{t("jobsIndicator.autoRefresh")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -304,7 +347,7 @@ export function JobsIndicator() {
|
|||||||
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||||
`}
|
`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
title={t("jobsIndicator.taskCount", { count: totalCount, plural: totalCount !== 1 ? "s" : "" })}
|
||||||
>
|
>
|
||||||
{/* Animated spinner for running jobs */}
|
{/* Animated spinner for running jobs */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { JobRow } from "./JobRow";
|
import { JobRow } from "./JobRow";
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
@@ -39,26 +40,23 @@ function formatDuration(start: string, end: string | null): string {
|
|||||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
|
|
||||||
if (diff < 3600000) {
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 1) return "Just now";
|
|
||||||
return `${mins}m ago`;
|
|
||||||
}
|
|
||||||
if (diff < 86400000) {
|
|
||||||
const hours = Math.floor(diff / 3600000);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const [jobs, setJobs] = useState(initialJobs);
|
const [jobs, setJobs] = useState(initialJobs);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||||
|
return date.toLocaleString(loc, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Refresh jobs list via SSE
|
// Refresh jobs list via SSE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventSource = new EventSource("/api/jobs/stream");
|
const eventSource = new EventSource("/api/jobs/stream");
|
||||||
@@ -102,15 +100,14 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border/60 bg-muted/50">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.id")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.library")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.type")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.status")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.stats")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.duration")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.created")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.actions")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "../components/ui";
|
import { Button } from "../components/ui";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface LibraryActionsProps {
|
interface LibraryActionsProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
monitorEnabled: boolean;
|
monitorEnabled: boolean;
|
||||||
scanMode: string;
|
scanMode: string;
|
||||||
watcherEnabled: boolean;
|
watcherEnabled: boolean;
|
||||||
|
metadataProvider: string | null;
|
||||||
|
fallbackMetadataProvider: string | null;
|
||||||
|
metadataRefreshMode: string;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,22 +22,14 @@ export function LibraryActions({
|
|||||||
monitorEnabled,
|
monitorEnabled,
|
||||||
scanMode,
|
scanMode,
|
||||||
watcherEnabled,
|
watcherEnabled,
|
||||||
onUpdate
|
metadataProvider,
|
||||||
|
fallbackMetadataProvider,
|
||||||
|
metadataRefreshMode,
|
||||||
}: LibraryActionsProps) {
|
}: LibraryActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = (formData: FormData) => {
|
const handleSubmit = (formData: FormData) => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -39,17 +37,28 @@ export function LibraryActions({
|
|||||||
const monitorEnabled = formData.get("monitor_enabled") === "true";
|
const monitorEnabled = formData.get("monitor_enabled") === "true";
|
||||||
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
||||||
const scanMode = formData.get("scan_mode") as string;
|
const scanMode = formData.get("scan_mode") as string;
|
||||||
|
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||||
|
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
||||||
|
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/libraries/${libraryId}/monitoring`, {
|
const [response] = await Promise.all([
|
||||||
method: "PATCH",
|
fetch(`/api/libraries/${libraryId}/monitoring`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
headers: { "Content-Type": "application/json" },
|
||||||
monitor_enabled: monitorEnabled,
|
body: JSON.stringify({
|
||||||
scan_mode: scanMode,
|
monitor_enabled: monitorEnabled,
|
||||||
watcher_enabled: watcherEnabled,
|
scan_mode: scanMode,
|
||||||
|
watcher_enabled: watcherEnabled,
|
||||||
|
metadata_refresh_mode: newMetadataRefreshMode,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -69,11 +78,11 @@ export function LibraryActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={isOpen ? "bg-accent" : ""}
|
className={isOpen ? "bg-accent" : ""}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -82,68 +91,201 @@ export function LibraryActions({
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && createPortal(
|
||||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
|
<>
|
||||||
<form action={handleSubmit}>
|
{/* Backdrop */}
|
||||||
<div className="space-y-4">
|
<div
|
||||||
<div className="flex items-center justify-between">
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
onClick={() => setIsOpen(false)}
|
||||||
<input
|
/>
|
||||||
type="checkbox"
|
|
||||||
name="monitor_enabled"
|
|
||||||
value="true"
|
|
||||||
defaultChecked={monitorEnabled}
|
|
||||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
|
||||||
/>
|
|
||||||
Auto Scan
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Modal */}
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
<input
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
type="checkbox"
|
{/* Header */}
|
||||||
name="watcher_enabled"
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||||
value="true"
|
<div className="flex items-center gap-2.5">
|
||||||
defaultChecked={watcherEnabled}
|
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
/>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
File Watcher ⚡
|
</svg>
|
||||||
</label>
|
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<div className="flex items-center justify-between">
|
type="button"
|
||||||
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
|
onClick={() => setIsOpen(false)}
|
||||||
<select
|
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
||||||
name="scan_mode"
|
|
||||||
defaultValue={scanMode}
|
|
||||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<option value="hourly">Hourly</option>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<option value="daily">Daily</option>
|
</svg>
|
||||||
<option value="weekly">Weekly</option>
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveError && (
|
{/* Form */}
|
||||||
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
<form action={handleSubmit}>
|
||||||
{saveError}
|
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
{/* Section: Indexation */}
|
||||||
type="submit"
|
<div className="space-y-5">
|
||||||
size="sm"
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||||
className="w-full"
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
disabled={isPending}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
>
|
</svg>
|
||||||
{isPending ? "Saving..." : "Save Settings"}
|
{t("libraryActions.sectionIndexation")}
|
||||||
</Button>
|
</h3>
|
||||||
|
|
||||||
|
{/* Auto scan */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="monitor_enabled"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={monitorEnabled}
|
||||||
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{t("libraryActions.autoScan")}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.autoScanDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
name="scan_mode"
|
||||||
|
defaultValue={scanMode}
|
||||||
|
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[130px] shrink-0"
|
||||||
|
>
|
||||||
|
<option value="manual">{t("monitoring.manual")}</option>
|
||||||
|
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||||
|
<option value="daily">{t("monitoring.daily")}</option>
|
||||||
|
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File watcher */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="watcher_enabled"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={watcherEnabled}
|
||||||
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{t("libraryActions.fileWatch")}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.fileWatchDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-border/40" />
|
||||||
|
|
||||||
|
{/* Section: Metadata */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
{t("libraryActions.sectionMetadata")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Provider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
|
{metadataProvider && metadataProvider !== "none" && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||||
|
{t("libraryActions.provider")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="metadata_provider"
|
||||||
|
defaultValue={metadataProvider || ""}
|
||||||
|
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||||
|
>
|
||||||
|
<option value="">{t("libraryActions.default")}</option>
|
||||||
|
<option value="none">{t("libraryActions.none")}</option>
|
||||||
|
<option value="google_books">Google Books</option>
|
||||||
|
<option value="comicvine">ComicVine</option>
|
||||||
|
<option value="open_library">Open Library</option>
|
||||||
|
<option value="anilist">AniList</option>
|
||||||
|
<option value="bedetheque">Bédéthèque</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.providerDesc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
|
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
|
||||||
|
{t("libraryActions.fallback")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="fallback_metadata_provider"
|
||||||
|
defaultValue={fallbackMetadataProvider || ""}
|
||||||
|
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||||
|
>
|
||||||
|
<option value="">{t("libraryActions.none")}</option>
|
||||||
|
<option value="google_books">Google Books</option>
|
||||||
|
<option value="comicvine">ComicVine</option>
|
||||||
|
<option value="open_library">Open Library</option>
|
||||||
|
<option value="anilist">AniList</option>
|
||||||
|
<option value="bedetheque">Bédéthèque</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.fallbackDesc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata refresh */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
|
||||||
|
<select
|
||||||
|
name="metadata_refresh_mode"
|
||||||
|
defaultValue={metadataRefreshMode}
|
||||||
|
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||||
|
>
|
||||||
|
<option value="manual">{t("monitoring.manual")}</option>
|
||||||
|
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||||
|
<option value="daily">{t("monitoring.daily")}</option>
|
||||||
|
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.metadataRefreshDesc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border/50 bg-muted/30">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? t("libraryActions.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { FolderPicker } from "./FolderPicker";
|
import { FolderPicker } from "./FolderPicker";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
import { Button, FormField, FormInput, FormRow } from "./ui";
|
import { Button, FormField, FormInput, FormRow } from "./ui";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface LibraryFormProps {
|
interface LibraryFormProps {
|
||||||
initialFolders: FolderItem[];
|
initialFolders: FolderItem[];
|
||||||
@@ -11,13 +12,14 @@ interface LibraryFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedPath, setSelectedPath] = useState<string>("");
|
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={action}>
|
<form action={action}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1 min-w-48">
|
<FormField className="flex-1 min-w-48">
|
||||||
<FormInput name="name" placeholder="Library name" required />
|
<FormInput name="name" placeholder={t("libraries.libraryName")} required />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField className="flex-1 min-w-64">
|
<FormField className="flex-1 min-w-64">
|
||||||
<input type="hidden" name="root_path" value={selectedPath} />
|
<input type="hidden" name="root_path" value={selectedPath} />
|
||||||
@@ -30,7 +32,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<Button type="submit" disabled={!selectedPath}>
|
<Button type="submit" disabled={!selectedPath}>
|
||||||
Add Library
|
{t("libraries.addButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, Badge } from "./ui";
|
import { Card, Badge } from "./ui";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
|
|
||||||
interface LibrarySubPageHeaderProps {
|
interface LibrarySubPageHeaderProps {
|
||||||
library: {
|
library: {
|
||||||
@@ -19,13 +20,14 @@ interface LibrarySubPageHeaderProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibrarySubPageHeader({
|
export async function LibrarySubPageHeader({
|
||||||
library,
|
library,
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
iconColor = "text-primary",
|
iconColor = "text-primary",
|
||||||
filterInfo
|
filterInfo
|
||||||
}: LibrarySubPageHeaderProps) {
|
}: LibrarySubPageHeaderProps) {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header avec breadcrumb intégré */}
|
{/* Header avec breadcrumb intégré */}
|
||||||
@@ -38,7 +40,7 @@ export function LibrarySubPageHeader({
|
|||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
Libraries
|
{t("libraryHeader.libraries")}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||||
@@ -73,8 +75,7 @@ export function LibrarySubPageHeader({
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
<span className="font-semibold">{library.book_count}</span>
|
<span className="text-muted-foreground ml-1">{t("libraryHeader.bookCount", { count: library.book_count, plural: library.book_count !== 1 ? "s" : "" })}</span>
|
||||||
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ export function LibrarySubPageHeader({
|
|||||||
variant={library.enabled ? "success" : "muted"}
|
variant={library.enabled ? "success" : "muted"}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{library.enabled ? "Enabled" : "Disabled"}
|
{library.enabled ? t("libraryHeader.enabled") : t("libraries.disabled")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
import { useRef, useCallback, useEffect } from "react";
|
import { useRef, useCallback, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
// SVG path data for filter icons, keyed by field name
|
||||||
|
const FILTER_ICONS: Record<string, string> = {
|
||||||
|
// Library - building/collection
|
||||||
|
library: "M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z",
|
||||||
|
// Reading status - open book
|
||||||
|
status: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||||
|
// Series status - signal/activity
|
||||||
|
series_status: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||||
|
// Missing books - warning triangle
|
||||||
|
has_missing: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
||||||
|
// Metadata provider - tag
|
||||||
|
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||||
|
// Sort - arrows up/down
|
||||||
|
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
||||||
|
// Format - document/file
|
||||||
|
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
|
||||||
|
// Metadata - link/chain
|
||||||
|
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||||
|
};
|
||||||
|
|
||||||
interface FieldDef {
|
interface FieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,11 +39,17 @@ interface LiveSearchFormProps {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = "filters:";
|
||||||
|
|
||||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
|
||||||
|
|
||||||
const buildUrl = useCallback((): string => {
|
const buildUrl = useCallback((): string => {
|
||||||
if (!formRef.current) return basePath;
|
if (!formRef.current) return basePath;
|
||||||
@@ -36,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
return qs ? `${basePath}?${qs}` : basePath;
|
return qs ? `${basePath}?${qs}` : basePath;
|
||||||
}, [basePath]);
|
}, [basePath]);
|
||||||
|
|
||||||
|
const saveFilters = useCallback(() => {
|
||||||
|
if (!formRef.current) return;
|
||||||
|
const formData = new FormData(formRef.current);
|
||||||
|
const filters: Record<string, string> = {};
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
const str = value.toString().trim();
|
||||||
|
if (str) filters[key] = str;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(filters));
|
||||||
|
} catch {}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
const navigate = useCallback((immediate: boolean) => {
|
const navigate = useCallback((immediate: boolean) => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
} else {
|
} else {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
}, debounceMs);
|
}, debounceMs);
|
||||||
}
|
}
|
||||||
}, [router, buildUrl, debounceMs]);
|
}, [router, buildUrl, debounceMs, saveFilters]);
|
||||||
|
|
||||||
|
// Restore filters from localStorage on mount if URL has no filters
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredRef.current) return;
|
||||||
|
restoredRef.current = true;
|
||||||
|
|
||||||
|
const hasUrlFilters = fields.some((f) => {
|
||||||
|
const val = searchParams.get(f.name);
|
||||||
|
return val && val.trim() !== "";
|
||||||
|
});
|
||||||
|
if (hasUrlFilters) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (!saved) return;
|
||||||
|
const filters: Record<string, string> = JSON.parse(saved);
|
||||||
|
const fieldNames = new Set(fields.map((f) => f.name));
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (fieldNames.has(key) && value) params.set(key, value);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
if (qs) {
|
||||||
|
router.replace(`${basePath}?${qs}` as any);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -58,70 +127,99 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
return val && val.trim() !== "";
|
return val && val.trim() !== "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const textFields = fields.filter((f) => f.type === "text");
|
||||||
|
const selectFields = fields.filter((f) => f.type === "select");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
saveFilters();
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
}}
|
}}
|
||||||
className="flex flex-col sm:flex-row gap-3 items-start sm:items-end"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{fields.map((field) =>
|
{/* Search input with icon */}
|
||||||
field.type === "text" ? (
|
{textFields.map((field) => (
|
||||||
<div key={field.name} className={field.className || "flex-1 w-full"}>
|
<div key={field.name} className="relative">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
<svg
|
||||||
{field.label}
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
||||||
</label>
|
fill="none"
|
||||||
<input
|
stroke="currentColor"
|
||||||
name={field.name}
|
viewBox="0 0 24 24"
|
||||||
type="text"
|
>
|
||||||
placeholder={field.placeholder}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
defaultValue={searchParams.get(field.name) || ""}
|
</svg>
|
||||||
onChange={() => navigate(false)}
|
<input
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
name={field.name}
|
||||||
/>
|
type="text"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
|
onChange={() => navigate(false)}
|
||||||
|
className="flex h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Filters row */}
|
||||||
|
{selectFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
{textFields.length > 0 && (
|
||||||
|
<div className="border-t border-border/60" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{selectFields.map((field) => (
|
||||||
|
<div key={field.name} className="flex items-center gap-1.5">
|
||||||
|
{FILTER_ICONS[field.name] && (
|
||||||
|
<svg className="w-3.5 h-3.5 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={FILTER_ICONS[field.name]} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<label className="text-xs font-medium text-muted-foreground whitespace-nowrap">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name={field.name}
|
||||||
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
|
onChange={() => navigate(true)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{field.options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
formRef.current?.reset();
|
||||||
|
try { localStorage.removeItem(storageKey); } catch {}
|
||||||
|
router.replace(basePath as any);
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
inline-flex items-center gap-1
|
||||||
|
h-8 px-2.5
|
||||||
|
text-xs font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
rounded-md
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors duration-200
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
{t("common.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</>
|
||||||
<div key={field.name} className={field.className || "w-full sm:w-48"}>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
|
||||||
{field.label}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name={field.name}
|
|
||||||
defaultValue={searchParams.get(field.name) || ""}
|
|
||||||
onChange={() => navigate(true)}
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
{field.options?.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{hasFilters && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.replace(basePath as any)}
|
|
||||||
className="
|
|
||||||
inline-flex items-center justify-center
|
|
||||||
h-10 px-4
|
|
||||||
border border-input
|
|
||||||
text-sm font-medium
|
|
||||||
text-muted-foreground
|
|
||||||
bg-background
|
|
||||||
rounded-md
|
|
||||||
hover:bg-accent hover:text-accent-foreground
|
|
||||||
transition-colors duration-200
|
|
||||||
w-full sm:w-auto
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface MarkBookReadButtonProps {
|
interface MarkBookReadButtonProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
@@ -10,12 +11,13 @@ interface MarkBookReadButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const isRead = currentStatus === "read";
|
const isRead = currentStatus === "read";
|
||||||
const targetStatus = isRead ? "unread" : "read";
|
const targetStatus = isRead ? "unread" : "read";
|
||||||
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
|
const label = isRead ? t("markRead.markUnread") : t("markRead.markAsRead");
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface MarkSeriesReadButtonProps {
|
interface MarkSeriesReadButtonProps {
|
||||||
seriesName: string;
|
seriesName: string;
|
||||||
@@ -10,12 +11,13 @@ interface MarkSeriesReadButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const allRead = booksReadCount >= bookCount;
|
const allRead = booksReadCount >= bookCount;
|
||||||
const targetStatus = allRead ? "unread" : "read";
|
const targetStatus = allRead ? "unread" : "read";
|
||||||
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
|
const label = allRead ? t("markRead.markUnread") : t("markRead.markAllRead");
|
||||||
|
|
||||||
const handleClick = async (e: React.MouseEvent) => {
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
696
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
696
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
|
||||||
|
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
const FIELD_KEYS: string[] = [
|
||||||
|
"description", "authors", "publishers", "start_year",
|
||||||
|
"total_volumes", "status", "summary", "isbn", "publish_date", "language",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
if (Array.isArray(value)) return value.join(", ");
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.length > 80 ? value.slice(0, 80) + "…" : value;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataSearchModalProps {
|
||||||
|
libraryId: string;
|
||||||
|
seriesName: string;
|
||||||
|
existingLink: ExternalMetadataLinkDto | null;
|
||||||
|
initialMissing: MissingBooksDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalStep = "idle" | "searching" | "results" | "confirm" | "syncing" | "done" | "linked";
|
||||||
|
|
||||||
|
export function MetadataSearchModal({
|
||||||
|
libraryId,
|
||||||
|
seriesName,
|
||||||
|
existingLink,
|
||||||
|
initialMissing,
|
||||||
|
}: MetadataSearchModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const fieldLabel = (field: string): string => {
|
||||||
|
if (FIELD_KEYS.includes(field)) {
|
||||||
|
return t(`field.${field}` as any);
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<ModalStep>("idle");
|
||||||
|
const [candidates, setCandidates] = useState<SeriesCandidateDto[]>([]);
|
||||||
|
const [selectedCandidate, setSelectedCandidate] = useState<SeriesCandidateDto | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [linkId, setLinkId] = useState<string | null>(existingLink?.id ?? null);
|
||||||
|
const [missing, setMissing] = useState<MissingBooksDto | null>(initialMissing);
|
||||||
|
const [showMissingList, setShowMissingList] = useState(false);
|
||||||
|
const [syncReport, setSyncReport] = useState<SyncReport | null>(null);
|
||||||
|
|
||||||
|
// Provider selector: empty string = library default
|
||||||
|
const [searchProvider, setSearchProvider] = useState("");
|
||||||
|
const [activeProvider, setActiveProvider] = useState("");
|
||||||
|
const [hiddenProviders, setHiddenProviders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Fetch metadata provider settings to hide providers without required API keys
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/metadata_providers")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => {
|
||||||
|
if (!data) return;
|
||||||
|
const hidden = new Set<string>();
|
||||||
|
// ComicVine requires an API key
|
||||||
|
if (!data.comicvine?.api_key) hidden.add("comicvine");
|
||||||
|
setHiddenProviders(hidden);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value));
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (existingLink && existingLink.status === "approved") {
|
||||||
|
setStep("linked");
|
||||||
|
} else {
|
||||||
|
doSearch("");
|
||||||
|
}
|
||||||
|
}, [existingLink]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setStep("idle");
|
||||||
|
setError(null);
|
||||||
|
setCandidates([]);
|
||||||
|
setSelectedCandidate(null);
|
||||||
|
setShowMissingList(false);
|
||||||
|
setSyncReport(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
|
async function doSearch(provider: string) {
|
||||||
|
setStep("searching");
|
||||||
|
setError(null);
|
||||||
|
setActiveProvider(provider);
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
library_id: libraryId,
|
||||||
|
series_name: seriesName,
|
||||||
|
};
|
||||||
|
if (provider) body.provider = provider;
|
||||||
|
|
||||||
|
const resp = await fetch("/api/metadata/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError(data.error || t("metadata.searchFailed"));
|
||||||
|
setStep("results");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCandidates(data);
|
||||||
|
// Update activeProvider from first result (the API returns the actual provider used)
|
||||||
|
if (data.length > 0 && data[0].provider) {
|
||||||
|
setActiveProvider(data[0].provider);
|
||||||
|
if (!provider) setSearchProvider(data[0].provider);
|
||||||
|
}
|
||||||
|
setStep("results");
|
||||||
|
} catch {
|
||||||
|
setError(t("common.networkError"));
|
||||||
|
setStep("results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectCandidate(candidate: SeriesCandidateDto) {
|
||||||
|
setSelectedCandidate(candidate);
|
||||||
|
setStep("confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove(syncSeries: boolean, syncBooks: boolean) {
|
||||||
|
if (!selectedCandidate) return;
|
||||||
|
setStep("syncing");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Create match — use the provider from the candidate
|
||||||
|
const matchResp = await fetch("/api/metadata/match", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
library_id: libraryId,
|
||||||
|
series_name: seriesName,
|
||||||
|
provider: selectedCandidate.provider,
|
||||||
|
external_id: selectedCandidate.external_id,
|
||||||
|
external_url: selectedCandidate.external_url,
|
||||||
|
confidence: selectedCandidate.confidence,
|
||||||
|
title: selectedCandidate.title,
|
||||||
|
metadata_json: {
|
||||||
|
...selectedCandidate.metadata_json,
|
||||||
|
description: selectedCandidate.description,
|
||||||
|
authors: selectedCandidate.authors,
|
||||||
|
publishers: selectedCandidate.publishers,
|
||||||
|
start_year: selectedCandidate.start_year,
|
||||||
|
},
|
||||||
|
total_volumes: selectedCandidate.total_volumes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const matchData = await matchResp.json();
|
||||||
|
if (!matchResp.ok) {
|
||||||
|
setError(matchData.error || t("metadata.linkFailed"));
|
||||||
|
setStep("results");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newLinkId = matchData.id;
|
||||||
|
setLinkId(newLinkId);
|
||||||
|
|
||||||
|
// Approve
|
||||||
|
const approveResp = await fetch("/api/metadata/approve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: newLinkId,
|
||||||
|
sync_series: syncSeries,
|
||||||
|
sync_books: syncBooks,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const approveData = await approveResp.json();
|
||||||
|
if (!approveResp.ok) {
|
||||||
|
setError(approveData.error || t("metadata.approveFailed"));
|
||||||
|
setStep("results");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store sync report
|
||||||
|
if (approveData.report) {
|
||||||
|
setSyncReport(approveData.report);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch missing books info
|
||||||
|
if (syncBooks) {
|
||||||
|
try {
|
||||||
|
const missingResp = await fetch(`/api/metadata/missing?id=${newLinkId}`);
|
||||||
|
if (missingResp.ok) {
|
||||||
|
setMissing(await missingResp.json());
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("done");
|
||||||
|
} catch {
|
||||||
|
setError(t("common.networkError"));
|
||||||
|
setStep("results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnlink() {
|
||||||
|
if (!linkId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/metadata/links?id=${linkId}`, { method: "DELETE" });
|
||||||
|
if (resp.ok) {
|
||||||
|
setLinkId(null);
|
||||||
|
setMissing(null);
|
||||||
|
handleClose();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceBadge(confidence: number) {
|
||||||
|
const color =
|
||||||
|
confidence >= 0.8
|
||||||
|
? "bg-green-500/10 text-green-600 border-green-500/30"
|
||||||
|
: confidence >= 0.5
|
||||||
|
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
|
||||||
|
: "bg-red-500/10 text-red-600 border-red-500/30";
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${color}`}>
|
||||||
|
{Math.round(confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = isOpen
|
||||||
|
? createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={handleClose}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Provider selector — visible during searching & results */}
|
||||||
|
{(step === "searching" || step === "results") && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-muted-foreground whitespace-nowrap">{t("metadata.provider")}</label>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{visibleProviders.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
disabled={step === "searching"}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchProvider(p.value);
|
||||||
|
doSearch(p.value);
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
|
||||||
|
(activeProvider || searchProvider) === p.value
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ProviderIcon provider={p.value} size={14} />
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SEARCHING */}
|
||||||
|
{step === "searching" && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||||
|
<span className="ml-3 text-muted-foreground">{t("metadata.searching", { name: seriesName })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ERROR */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RESULTS */}
|
||||||
|
{step === "results" && (
|
||||||
|
<>
|
||||||
|
{candidates.length === 0 && !error ? (
|
||||||
|
<p className="text-muted-foreground text-center py-8">{t("metadata.noResults")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{t("metadata.resultCount", { count: candidates.length, plural: candidates.length !== 1 ? "s" : "" })}
|
||||||
|
{activeProvider && (
|
||||||
|
<span className="ml-1 text-xs inline-flex items-center gap-1">{t("common.via")} <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{candidates.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectCandidate(c)}
|
||||||
|
className="w-full text-left px-3 py-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<div className="w-10 h-14 flex-shrink-0 rounded bg-muted/50 overflow-hidden">
|
||||||
|
{c.cover_url ? (
|
||||||
|
<img src={c.cover_url} alt={c.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground/40">
|
||||||
|
<Icon name="image" size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-foreground truncate">{c.title}</span>
|
||||||
|
{confidenceBadge(c.confidence)}
|
||||||
|
</div>
|
||||||
|
{c.authors.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{c.authors.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{c.publishers.length > 0 && <span>{c.publishers[0]}</span>}
|
||||||
|
{c.start_year != null && <span>{c.start_year}</span>}
|
||||||
|
{c.total_volumes != null && (
|
||||||
|
<span>
|
||||||
|
{c.total_volumes} {c.metadata_json?.volume_source === "chapters" ? "ch." : "vol."}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.metadata_json?.status === "RELEASING" && (
|
||||||
|
<span className="italic text-amber-500">{t("metadata.inProgress")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONFIRM */}
|
||||||
|
{step === "confirm" && selectedCandidate && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{selectedCandidate.cover_url && (
|
||||||
|
<img
|
||||||
|
src={selectedCandidate.cover_url}
|
||||||
|
alt={selectedCandidate.title}
|
||||||
|
className="w-16 h-22 object-cover rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground">{selectedCandidate.title}</h4>
|
||||||
|
{selectedCandidate.authors.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedCandidate.authors.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
{selectedCandidate.total_volumes != null && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? t("metadata.chapters") : t("metadata.volumes")}
|
||||||
|
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">({t("metadata.inProgress")})</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
|
||||||
|
{t("common.via")} <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-foreground font-medium">{t("metadata.howToSync")}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleApprove(true, false)}
|
||||||
|
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesOnly")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesOnlyDesc")}</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleApprove(true, true)}
|
||||||
|
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesAndBooks")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesAndBooksDesc")}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("metadata.backToResults")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SYNCING */}
|
||||||
|
{step === "syncing" && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||||
|
<span className="ml-3 text-muted-foreground">{t("metadata.syncingMetadata")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DONE */}
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||||
|
<p className="font-medium text-green-600">{t("metadata.syncSuccess")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sync Report */}
|
||||||
|
{syncReport && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Series report */}
|
||||||
|
{syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (
|
||||||
|
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">{t("metadata.seriesLabel")}</p>
|
||||||
|
{syncReport.series.fields_updated.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{syncReport.series.fields_updated.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 text-green-600">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">{fieldLabel(f.field)}</span>
|
||||||
|
<span className="text-muted-foreground truncate max-w-[200px]">{formatValue(f.new_value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncReport.series.fields_skipped.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{syncReport.series.fields_skipped.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-amber-500">
|
||||||
|
<svg className="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||||
|
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books message (e.g. provider has no volume data) */}
|
||||||
|
{syncReport.books_message && (
|
||||||
|
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||||
|
<p className="text-xs text-amber-600">{syncReport.books_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books report */}
|
||||||
|
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||||
|
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{t("metadata.booksLabel")} — {t("metadata.booksMatched", { matched: syncReport.books_matched, plural: syncReport.books_matched !== 1 ? "s" : "" })}{syncReport.books_unmatched > 0 && `, ${t("metadata.booksUnmatched", { count: syncReport.books_unmatched, plural: syncReport.books_unmatched !== 1 ? "s" : "" })}`}
|
||||||
|
</p>
|
||||||
|
{syncReport.books.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{syncReport.books.map((b, i) => (
|
||||||
|
<div key={i} className="text-xs">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{b.volume != null && <span className="font-mono text-muted-foreground mr-1.5">#{b.volume}</span>}
|
||||||
|
{b.title}
|
||||||
|
</p>
|
||||||
|
<div className="ml-4 space-y-0.5 mt-0.5">
|
||||||
|
{b.fields_updated.map((f, j) => (
|
||||||
|
<p key={j} className="flex items-center gap-1.5 text-green-600">
|
||||||
|
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||||
|
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{b.fields_skipped.map((f, j) => (
|
||||||
|
<p key={`s${j}`} className="flex items-center gap-1.5 text-amber-500">
|
||||||
|
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||||
|
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Missing books */}
|
||||||
|
{missing && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||||
|
<p className="text-2xl font-semibold">{missing.total_external}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||||
|
<p className="text-2xl font-semibold">{missing.total_local}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||||
|
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{missing.missing_books.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMissingList(!showMissingList)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||||
|
{t("metadata.missingBooks", { count: missing.missing_count, plural: missing.missing_count !== 1 ? "s" : "" })}
|
||||||
|
</button>
|
||||||
|
{showMissingList && (
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||||
|
{missing.missing_books.map((b, i) => (
|
||||||
|
<p key={i} className="text-muted-foreground truncate">
|
||||||
|
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||||
|
{b.title || t("metadata.unknown")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { handleClose(); router.refresh(); }}
|
||||||
|
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LINKED (already approved) */}
|
||||||
|
{step === "linked" && existingLink && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-primary/5 border border-primary/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
|
||||||
|
{t("metadata.linkedTo")} <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
|
||||||
|
</p>
|
||||||
|
{existingLink.external_url && (
|
||||||
|
<a
|
||||||
|
href={existingLink.external_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block mt-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("metadata.viewExternal")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{existingLink.confidence != null && confidenceBadge(existingLink.confidence)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initialMissing && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||||
|
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||||
|
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||||
|
<p className="text-2xl font-semibold text-warning">{initialMissing.missing_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initialMissing && initialMissing.missing_books.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMissingList(!showMissingList)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||||
|
{t("metadata.missingBooks", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
|
||||||
|
</button>
|
||||||
|
{showMissingList && (
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||||
|
{initialMissing.missing_books.map((b, i) => (
|
||||||
|
<p key={i} className="text-muted-foreground truncate">
|
||||||
|
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||||
|
{b.title || t("metadata.unknown")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { doSearch(""); }}
|
||||||
|
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("metadata.searchAgain")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnlink}
|
||||||
|
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
{t("metadata.unlink")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{existingLink && existingLink.status === "approved" && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
||||||
|
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||||
|
<span>{providerLabel(existingLink.provider)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { NavIcon } from "./ui";
|
import { NavIcon } from "./ui";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
label: string;
|
label: string;
|
||||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const HamburgerIcon = () => (
|
const HamburgerIcon = () => (
|
||||||
@@ -24,6 +25,7 @@ const XIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="h-16 border-b border-border/40 flex items-center px-4">
|
<div className="h-16 border-b border-border/40 flex items-center px-4">
|
||||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">Navigation</span>
|
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">{t("nav.navigation")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1 p-3 flex-1">
|
<nav className="flex flex-col gap-1 p-3 flex-1">
|
||||||
@@ -76,7 +78,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<NavIcon name="settings" />
|
<NavIcon name="settings" />
|
||||||
<span className="font-medium">Settings</span>
|
<span className="font-medium">{t("nav.settings")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -90,7 +92,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
|||||||
<button
|
<button
|
||||||
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
aria-label={isOpen ? t("nav.closeMenu") : t("nav.openMenu")}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface MonitoringFormProps {
|
interface MonitoringFormProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -10,6 +11,7 @@ interface MonitoringFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEnabled }: MonitoringFormProps) {
|
export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEnabled }: MonitoringFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleSubmit = (formData: FormData) => {
|
const handleSubmit = (formData: FormData) => {
|
||||||
@@ -51,7 +53,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span>Auto</span>
|
<span>{t("monitoring.auto")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||||
@@ -67,7 +69,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
||||||
/>
|
/>
|
||||||
<span title="Real-time file watcher">⚡</span>
|
<span title={t("monitoring.fileWatch")}>⚡</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@@ -76,10 +78,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">{t("monitoring.manual")}</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">{t("monitoring.daily")}</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type Period = "day" | "week" | "month";
|
||||||
|
|
||||||
|
export function PeriodToggle({
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
labels: { day: string; week: string; month: string };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const raw = searchParams.get("period");
|
||||||
|
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||||
|
|
||||||
|
function setPeriod(period: Period) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (period === "month") {
|
||||||
|
params.delete("period");
|
||||||
|
} else {
|
||||||
|
params.set("period", period);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Period[] = ["day", "week", "month"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||||
|
{options.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
current === p
|
||||||
|
? "bg-card text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labels[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal file
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/** Inline SVG icons for metadata providers */
|
||||||
|
|
||||||
|
interface ProviderIconProps {
|
||||||
|
provider: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderIcon({ provider, size = 16, className = "" }: ProviderIconProps) {
|
||||||
|
const style = { width: size, height: size, flexShrink: 0 };
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case "google_books":
|
||||||
|
// Stylized book (Google Books)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||||
|
<path
|
||||||
|
d="M21 4H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"
|
||||||
|
fill="#4285F4"
|
||||||
|
opacity="0.15"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 4v16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#4285F4"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M7 8h3M7 11h3M14 8h3M14 11h3" stroke="#4285F4" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "open_library":
|
||||||
|
// Open book (Open Library)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||||
|
<path
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
fill="none"
|
||||||
|
stroke="#E8590C"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "comicvine":
|
||||||
|
// Explosion / star burst (ComicVine)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||||
|
<path
|
||||||
|
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
|
||||||
|
fill="#E7272D"
|
||||||
|
opacity="0.15"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#E7272D"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "anilist":
|
||||||
|
// Stylized play / triangle (AniList)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="3" fill="#02A9FF" opacity="0.15" />
|
||||||
|
<path
|
||||||
|
d="M8 6h2.5l4 12H12l-.75-2.5H7.75L7 18H4.5L8 6zm-.25 7.5h3.5L9.5 8.25 7.75 13.5z"
|
||||||
|
fill="#02A9FF"
|
||||||
|
/>
|
||||||
|
<path d="M16 10h2.5v8H16z" fill="#02A9FF" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bedetheque":
|
||||||
|
// French flag-inspired book (Bédéthèque)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className}>
|
||||||
|
<rect x="3" y="4" width="6" height="16" rx="1" fill="#002395" opacity="0.2" />
|
||||||
|
<rect x="9" y="4" width="6" height="16" fill="#FFFFFF" opacity="0.1" />
|
||||||
|
<rect x="15" y="4" width="6" height="16" rx="1" fill="#ED2939" opacity="0.2" />
|
||||||
|
<path
|
||||||
|
d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#002395"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M8 9h8M8 12h6M8 15h4" stroke="#002395" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Generic globe
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" style={style} className={className} fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDERS = [
|
||||||
|
{ value: "google_books", label: "Google Books" },
|
||||||
|
{ value: "open_library", label: "Open Library" },
|
||||||
|
{ value: "comicvine", label: "ComicVine" },
|
||||||
|
{ value: "anilist", label: "AniList" },
|
||||||
|
{ value: "bedetheque", label: "Bédéthèque" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function providerLabel(value: string) {
|
||||||
|
return PROVIDERS.find((p) => p.value === value)?.label ?? value.replace("_", " ");
|
||||||
|
}
|
||||||
383
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
383
apps/backoffice/app/components/ProwlarrSearchModal.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
import type { ProwlarrRelease, ProwlarrSearchResponse } from "../../lib/api";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
interface MissingBookItem {
|
||||||
|
title: string | null;
|
||||||
|
volume_number: number | null;
|
||||||
|
external_book_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProwlarrSearchModalProps {
|
||||||
|
seriesName: string;
|
||||||
|
missingBooks: MissingBookItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB";
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + " KB";
|
||||||
|
return bytes + " B";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearchModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isConfigured, setIsConfigured] = useState<boolean | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [results, setResults] = useState<ProwlarrRelease[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// qBittorrent state
|
||||||
|
const [isQbConfigured, setIsQbConfigured] = useState(false);
|
||||||
|
const [sendingGuid, setSendingGuid] = useState<string | null>(null);
|
||||||
|
const [sentGuids, setSentGuids] = useState<Set<string>>(new Set());
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check if Prowlarr and qBittorrent are configured on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings/prowlarr")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
setIsConfigured(!!(data && data.api_key && data.api_key.trim()));
|
||||||
|
})
|
||||||
|
.catch(() => setIsConfigured(false));
|
||||||
|
fetch("/api/settings/qbittorrent")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
setIsQbConfigured(!!(data && data.url && data.url.trim() && data.username && data.username.trim()));
|
||||||
|
})
|
||||||
|
.catch(() => setIsQbConfigured(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState(`"${seriesName}"`);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (queryOverride?: string) => {
|
||||||
|
const searchQuery = queryOverride ?? searchInput;
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
setError(null);
|
||||||
|
setResults([]);
|
||||||
|
try {
|
||||||
|
const missing_volumes = missingBooks?.map((b) => ({
|
||||||
|
volume_number: b.volume_number,
|
||||||
|
title: b.title,
|
||||||
|
})) ?? undefined;
|
||||||
|
const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes };
|
||||||
|
const resp = await fetch("/api/prowlarr/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
} else {
|
||||||
|
const searchResp = data as ProwlarrSearchResponse;
|
||||||
|
setResults(searchResp.results);
|
||||||
|
setQuery(searchResp.query);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("prowlarr.searchError"));
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [t, seriesName, searchInput]);
|
||||||
|
|
||||||
|
const defaultQuery = `"${seriesName}"`;
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
setIsOpen(true);
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
setQuery("");
|
||||||
|
setSearchInput(defaultQuery);
|
||||||
|
// Auto-search the series on open
|
||||||
|
doSearch(defaultQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendToQbittorrent(downloadUrl: string, guid: string) {
|
||||||
|
setSendingGuid(guid);
|
||||||
|
setSendError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/qbittorrent/add", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url: downloadUrl }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
setSendError(data.error);
|
||||||
|
} else if (data.success) {
|
||||||
|
setSentGuids((prev) => new Set(prev).add(guid));
|
||||||
|
} else {
|
||||||
|
setSendError(data.message || t("prowlarr.sentError"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSendError(t("prowlarr.sentError"));
|
||||||
|
} finally {
|
||||||
|
setSendingGuid(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render button if not configured
|
||||||
|
if (isConfigured === false) return null;
|
||||||
|
if (isConfigured === null) return null;
|
||||||
|
|
||||||
|
const modal = isOpen
|
||||||
|
? createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||||
|
<h3 className="font-semibold text-foreground">{t("prowlarr.modalTitle")}</h3>
|
||||||
|
<button type="button" onClick={handleClose}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Search input */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchInput.trim()) doSearch(searchInput.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||||
|
placeholder={t("prowlarr.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching || !searchInput.trim()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{t("prowlarr.searchAction")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Quick search badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{seriesName}
|
||||||
|
</button>
|
||||||
|
{missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => {
|
||||||
|
const label = book.title || `Vol. ${book.volume_number}`;
|
||||||
|
const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSearchInput(q); doSearch(q); }}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Searching indicator */}
|
||||||
|
{isSearching && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||||
|
{t("prowlarr.searching")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!isSearching && results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })}
|
||||||
|
{query && <span className="ml-1 text-xs opacity-70">({query})</span>}
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-left">
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnTitle")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnIndexer")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-right">{t("prowlarr.columnSize")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnSeeders")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-center">{t("prowlarr.columnLeechers")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground">{t("prowlarr.columnProtocol")}</th>
|
||||||
|
<th className="px-3 py-2 font-medium text-muted-foreground text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{results.map((release, i) => {
|
||||||
|
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
|
||||||
|
return (
|
||||||
|
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
||||||
|
<td className="px-3 py-2 max-w-[400px]">
|
||||||
|
<span className="truncate block" title={release.title}>
|
||||||
|
{release.title}
|
||||||
|
</span>
|
||||||
|
{hasMissing && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
{release.matchedMissingVolumes!.map((vol) => (
|
||||||
|
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||||
|
{t("prowlarr.missingVol", { vol })}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{release.indexer || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{release.size > 0 ? formatSize(release.size) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{release.seeders != null ? (
|
||||||
|
<span className={release.seeders > 0 ? "text-green-500 font-medium" : "text-muted-foreground"}>
|
||||||
|
{release.seeders}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center text-muted-foreground">
|
||||||
|
{release.leechers != null ? release.leechers : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{release.protocol && (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
release.protocol === "torrent"
|
||||||
|
? "bg-blue-500/15 text-blue-600"
|
||||||
|
: "bg-amber-500/15 text-amber-600"
|
||||||
|
}`}>
|
||||||
|
{release.protocol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{isQbConfigured && release.downloadUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSendToQbittorrent(release.downloadUrl!, release.guid)}
|
||||||
|
disabled={sendingGuid === release.guid || sentGuids.has(release.guid)}
|
||||||
|
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 ${
|
||||||
|
sentGuids.has(release.guid)
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-primary hover:bg-primary/10"
|
||||||
|
}`}
|
||||||
|
title={sentGuids.has(release.guid) ? t("prowlarr.sentSuccess") : t("prowlarr.sendToQbittorrent")}
|
||||||
|
>
|
||||||
|
{sendingGuid === release.guid ? (
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||||
|
) : sentGuids.has(release.guid) ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 8l4 4 6-7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 8V14H2V2H8M10 2H14V6M14 2L7 9" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{release.downloadUrl && (
|
||||||
|
<a
|
||||||
|
href={release.downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
title={t("prowlarr.download")}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M8 2v8M4 7l4 4 4-4M2 13h12" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{release.infoUrl && (
|
||||||
|
<a
|
||||||
|
href={release.infoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
title={t("prowlarr.info")}
|
||||||
|
>
|
||||||
|
<Icon name="externalLink" size="sm" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* qBittorrent send error */}
|
||||||
|
{sendError && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results */}
|
||||||
|
{!isSearching && !error && query && results.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("prowlarr.noResults")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{t("prowlarr.searchButton")}
|
||||||
|
</button>
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/backoffice/app/components/SafeHtml.tsx
Normal file
29
apps/backoffice/app/components/SafeHtml.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface SafeHtmlProps {
|
||||||
|
html: string;
|
||||||
|
className?: string;
|
||||||
|
as?: "div" | "p" | "span";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeOptions: sanitizeHtml.IOptions = {
|
||||||
|
allowedTags: [
|
||||||
|
"b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "span",
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "target", "rel"],
|
||||||
|
span: ["class"],
|
||||||
|
},
|
||||||
|
transformTags: {
|
||||||
|
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SafeHtml({ html, className, as: tag = "div" }: SafeHtmlProps) {
|
||||||
|
return React.createElement(tag, {
|
||||||
|
className,
|
||||||
|
dangerouslySetInnerHTML: { __html: sanitizeHtml(html, sanitizeOptions) },
|
||||||
|
});
|
||||||
|
}
|
||||||
53
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
53
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
interface SeriesFiltersProps {
|
||||||
|
basePath: string;
|
||||||
|
currentSeriesStatus?: string;
|
||||||
|
currentHasMissing: boolean;
|
||||||
|
seriesStatusOptions: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const updateFilter = useCallback((key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
params.delete("page");
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(`${basePath}${qs ? `?${qs}` : ""}` as any);
|
||||||
|
}, [router, searchParams, basePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<select
|
||||||
|
value={currentSeriesStatus || ""}
|
||||||
|
onChange={(e) => updateFilter("series_status", e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||||
|
>
|
||||||
|
{seriesStatusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentHasMissing ? "true" : ""}
|
||||||
|
onChange={(e) => updateFilter("has_missing", e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("seriesFilters.all")}</option>
|
||||||
|
<option value="true">{t("seriesFilters.missingBooks")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "../../../lib/i18n/context";
|
||||||
|
|
||||||
type BadgeVariant =
|
type BadgeVariant =
|
||||||
| "default"
|
| "default"
|
||||||
@@ -70,19 +73,19 @@ const statusVariants: Record<string, BadgeVariant> = {
|
|||||||
unread: "unread",
|
unread: "unread",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
extracting_pages: "Extracting pages",
|
|
||||||
generating_thumbnails: "Thumbnails",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status: string;
|
status: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const key = status.toLowerCase();
|
const key = status.toLowerCase();
|
||||||
const variant = statusVariants[key] || "default";
|
const variant = statusVariants[key] || "default";
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
extracting_pages: t("statusBadge.extracting_pages"),
|
||||||
|
generating_thumbnails: t("statusBadge.generating_thumbnails"),
|
||||||
|
};
|
||||||
const label = statusLabels[key] ?? status;
|
const label = statusLabels[key] ?? status;
|
||||||
return <Badge variant={variant} className={className}>{label}</Badge>;
|
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||||
}
|
}
|
||||||
@@ -90,27 +93,31 @@ export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
|||||||
// Job type badge
|
// Job type badge
|
||||||
const jobTypeVariants: Record<string, BadgeVariant> = {
|
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||||
rebuild: "primary",
|
rebuild: "primary",
|
||||||
|
rescan: "primary",
|
||||||
full_rebuild: "warning",
|
full_rebuild: "warning",
|
||||||
thumbnail_rebuild: "secondary",
|
thumbnail_rebuild: "secondary",
|
||||||
thumbnail_regenerate: "warning",
|
thumbnail_regenerate: "warning",
|
||||||
};
|
};
|
||||||
|
|
||||||
const jobTypeLabels: Record<string, string> = {
|
|
||||||
rebuild: "Index",
|
|
||||||
full_rebuild: "Full Index",
|
|
||||||
thumbnail_rebuild: "Thumbnails",
|
|
||||||
thumbnail_regenerate: "Regen. Thumbnails",
|
|
||||||
cbr_to_cbz: "CBR → CBZ",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface JobTypeBadgeProps {
|
interface JobTypeBadgeProps {
|
||||||
type: string;
|
type: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const key = type.toLowerCase();
|
const key = type.toLowerCase();
|
||||||
const variant = jobTypeVariants[key] || "default";
|
const variant = jobTypeVariants[key] || "default";
|
||||||
|
const jobTypeLabels: Record<string, string> = {
|
||||||
|
rebuild: t("jobType.rebuild"),
|
||||||
|
rescan: t("jobType.rescan"),
|
||||||
|
full_rebuild: t("jobType.full_rebuild"),
|
||||||
|
thumbnail_rebuild: t("jobType.thumbnail_rebuild"),
|
||||||
|
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
|
||||||
|
cbr_to_cbz: t("jobType.cbr_to_cbz"),
|
||||||
|
metadata_batch: t("jobType.metadata_batch"),
|
||||||
|
metadata_refresh: t("jobType.metadata_refresh"),
|
||||||
|
};
|
||||||
const label = jobTypeLabels[key] ?? type;
|
const label = jobTypeLabels[key] ?? type;
|
||||||
return <Badge variant={variant} className={className}>{label}</Badge>;
|
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ type IconName =
|
|||||||
| "play"
|
| "play"
|
||||||
| "stop"
|
| "stop"
|
||||||
| "spinner"
|
| "spinner"
|
||||||
| "warning";
|
| "warning"
|
||||||
|
| "tag"
|
||||||
|
| "document"
|
||||||
|
| "authors"
|
||||||
|
| "bell";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
@@ -82,6 +86,10 @@ const icons: Record<IconName, string> = {
|
|||||||
stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z",
|
stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z",
|
||||||
spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
||||||
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
||||||
|
tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||||
|
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||||
|
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||||
|
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClasses: Partial<Record<IconName, string>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
@@ -95,6 +103,7 @@ const colorClasses: Partial<Record<IconName, string>> = {
|
|||||||
image: "text-primary",
|
image: "text-primary",
|
||||||
cache: "text-warning",
|
cache: "text-warning",
|
||||||
performance: "text-success",
|
performance: "text-success",
|
||||||
|
authors: "text-violet-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { IconButton } from "./Button";
|
import { IconButton } from "./Button";
|
||||||
|
import { useTranslation } from "../../../lib/i18n/context";
|
||||||
|
|
||||||
interface CursorPaginationProps {
|
interface CursorPaginationProps {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
@@ -23,6 +24,7 @@ export function CursorPagination({
|
|||||||
}: CursorPaginationProps) {
|
}: CursorPaginationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const goToNext = () => {
|
const goToNext = () => {
|
||||||
if (!nextCursor) return;
|
if (!nextCursor) return;
|
||||||
@@ -48,7 +50,7 @@ export function CursorPagination({
|
|||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
{/* Page size selector */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted-foreground">Show</span>
|
<span className="text-sm text-muted-foreground">{t("pagination.show")}</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
@@ -60,12 +62,12 @@ export function CursorPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">per page</span>
|
<span className="text-sm text-muted-foreground">{t("common.perPage")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count info */}
|
{/* Count info */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing {currentCount} items
|
{t("pagination.displaying", { count: currentCount.toString() })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -79,7 +81,7 @@ export function CursorPagination({
|
|||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
First
|
{t("common.first")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -88,7 +90,7 @@ export function CursorPagination({
|
|||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
Next
|
{t("common.next")}
|
||||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -115,6 +117,7 @@ export function OffsetPagination({
|
|||||||
}: OffsetPaginationProps) {
|
}: OffsetPaginationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const goToPage = (page: number) => {
|
const goToPage = (page: number) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
@@ -170,7 +173,7 @@ export function OffsetPagination({
|
|||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
{/* Page size selector */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted-foreground">Show</span>
|
<span className="text-sm text-muted-foreground">{t("pagination.show")}</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
@@ -182,12 +185,12 @@ export function OffsetPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">per page</span>
|
<span className="text-sm text-muted-foreground">{t("common.perPage")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page info */}
|
{/* Page info */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{startItem}-{endItem} of {totalItems}
|
{t("pagination.range", { start: startItem.toString(), end: endItem.toString(), total: totalItems.toString() })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page navigation */}
|
{/* Page navigation */}
|
||||||
@@ -196,7 +199,7 @@ export function OffsetPagination({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
title="Previous page"
|
title={t("common.previousPage")}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
@@ -224,7 +227,7 @@ export function OffsetPagination({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
title="Next page"
|
title={t("common.nextPage")}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch } from "../../../lib/api";
|
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
} from "../../components/ui";
|
} from "../../components/ui";
|
||||||
|
import { JobDetailLive } from "../../components/JobDetailLive";
|
||||||
|
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -42,33 +46,6 @@ interface JobError {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
|
||||||
rebuild: {
|
|
||||||
label: "Incremental index",
|
|
||||||
description: "Scans for new/modified files, analyzes them and generates missing thumbnails.",
|
|
||||||
isThumbnailOnly: false,
|
|
||||||
},
|
|
||||||
full_rebuild: {
|
|
||||||
label: "Full re-index",
|
|
||||||
description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.",
|
|
||||||
isThumbnailOnly: false,
|
|
||||||
},
|
|
||||||
thumbnail_rebuild: {
|
|
||||||
label: "Thumbnail rebuild",
|
|
||||||
description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.",
|
|
||||||
isThumbnailOnly: true,
|
|
||||||
},
|
|
||||||
thumbnail_regenerate: {
|
|
||||||
label: "Thumbnail regeneration",
|
|
||||||
description: "Regenerates all thumbnails from scratch, replacing existing ones.",
|
|
||||||
isThumbnailOnly: true,
|
|
||||||
},
|
|
||||||
cbr_to_cbz: {
|
|
||||||
label: "CBR → CBZ conversion",
|
|
||||||
description: "Converts a CBR archive to the open CBZ format.",
|
|
||||||
isThumbnailOnly: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
|
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +89,70 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
|
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||||
|
rebuild: {
|
||||||
|
label: t("jobType.rebuildLabel"),
|
||||||
|
description: t("jobType.rebuildDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
full_rebuild: {
|
||||||
|
label: t("jobType.full_rebuildLabel"),
|
||||||
|
description: t("jobType.full_rebuildDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
rescan: {
|
||||||
|
label: t("jobType.rescanLabel"),
|
||||||
|
description: t("jobType.rescanDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
thumbnail_rebuild: {
|
||||||
|
label: t("jobType.thumbnail_rebuildLabel"),
|
||||||
|
description: t("jobType.thumbnail_rebuildDesc"),
|
||||||
|
isThumbnailOnly: true,
|
||||||
|
},
|
||||||
|
thumbnail_regenerate: {
|
||||||
|
label: t("jobType.thumbnail_regenerateLabel"),
|
||||||
|
description: t("jobType.thumbnail_regenerateDesc"),
|
||||||
|
isThumbnailOnly: true,
|
||||||
|
},
|
||||||
|
cbr_to_cbz: {
|
||||||
|
label: t("jobType.cbr_to_cbzLabel"),
|
||||||
|
description: t("jobType.cbr_to_cbzDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
metadata_batch: {
|
||||||
|
label: t("jobType.metadata_batchLabel"),
|
||||||
|
description: t("jobType.metadata_batchDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
metadata_refresh: {
|
||||||
|
label: t("jobType.metadata_refreshLabel"),
|
||||||
|
description: t("jobType.metadata_refreshDesc"),
|
||||||
|
isThumbnailOnly: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMetadataBatch = job.type === "metadata_batch";
|
||||||
|
const isMetadataRefresh = job.type === "metadata_refresh";
|
||||||
|
|
||||||
|
// Fetch batch report & results for metadata_batch jobs
|
||||||
|
let batchReport: MetadataBatchReportDto | null = null;
|
||||||
|
let batchResults: MetadataBatchResultDto[] = [];
|
||||||
|
if (isMetadataBatch) {
|
||||||
|
[batchReport, batchResults] = await Promise.all([
|
||||||
|
getMetadataBatchReport(id).catch(() => null),
|
||||||
|
getMetadataBatchResults(id).catch(() => []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch refresh report for metadata_refresh jobs
|
||||||
|
let refreshReport: MetadataRefreshReportDto | null = null;
|
||||||
|
if (isMetadataRefresh) {
|
||||||
|
refreshReport = await getMetadataRefreshReport(id).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
||||||
label: job.type,
|
label: job.type,
|
||||||
description: null,
|
description: null,
|
||||||
@@ -125,27 +166,36 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
const isCompleted = job.status === "success";
|
const isCompleted = job.status === "success";
|
||||||
const isFailed = job.status === "failed";
|
const isFailed = job.status === "failed";
|
||||||
const isCancelled = job.status === "cancelled";
|
const isCancelled = job.status === "cancelled";
|
||||||
|
const isTerminal = isCompleted || isFailed || isCancelled;
|
||||||
const isExtractingPages = job.status === "extracting_pages";
|
const isExtractingPages = job.status === "extracting_pages";
|
||||||
const isThumbnailPhase = job.status === "generating_thumbnails";
|
const isThumbnailPhase = job.status === "generating_thumbnails";
|
||||||
const isPhase2 = isExtractingPages || isThumbnailPhase;
|
const isPhase2 = isExtractingPages || isThumbnailPhase;
|
||||||
const { isThumbnailOnly } = typeInfo;
|
const { isThumbnailOnly } = typeInfo;
|
||||||
|
|
||||||
// Which label to use for the progress card
|
// Which label to use for the progress card
|
||||||
const progressTitle = isThumbnailOnly
|
const progressTitle = isMetadataBatch
|
||||||
? "Thumbnails"
|
? t("jobDetail.metadataSearch")
|
||||||
: isExtractingPages
|
: isMetadataRefresh
|
||||||
? "Phase 2 — Extracting pages"
|
? t("jobDetail.metadataRefresh")
|
||||||
: isThumbnailPhase
|
: isThumbnailOnly
|
||||||
? "Phase 2 — Thumbnails"
|
? t("jobType.thumbnail_rebuild")
|
||||||
: "Phase 1 — Discovery";
|
: isExtractingPages
|
||||||
|
? t("jobDetail.phase2a")
|
||||||
|
: isThumbnailPhase
|
||||||
|
? t("jobDetail.phase2b")
|
||||||
|
: t("jobDetail.phase1");
|
||||||
|
|
||||||
const progressDescription = isThumbnailOnly
|
const progressDescription = isMetadataBatch
|
||||||
? undefined
|
? t("jobDetail.metadataSearchDesc")
|
||||||
: isExtractingPages
|
: isMetadataRefresh
|
||||||
? "Extracting first page from each archive (page count + raw image)"
|
? t("jobDetail.metadataRefreshDesc")
|
||||||
: isThumbnailPhase
|
: isThumbnailOnly
|
||||||
? "Generating thumbnails for the analyzed books"
|
? undefined
|
||||||
: "Scanning and indexing files in the library";
|
: isExtractingPages
|
||||||
|
? t("jobDetail.phase2aDesc")
|
||||||
|
: isThumbnailPhase
|
||||||
|
? t("jobDetail.phase2bDesc")
|
||||||
|
: t("jobDetail.phase1Desc");
|
||||||
|
|
||||||
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||||
const speedCount = isThumbnailOnly
|
const speedCount = isThumbnailOnly
|
||||||
@@ -158,6 +208,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JobDetailLive jobId={id} isTerminal={isTerminal} />
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
@@ -166,9 +217,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
Back to jobs
|
{t("jobDetail.backToJobs")}
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
<h1 className="text-3xl font-bold text-foreground mt-2">{t("jobDetail.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary banner — completed */}
|
{/* Summary banner — completed */}
|
||||||
@@ -178,19 +229,29 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-sm text-success">
|
<div className="text-sm text-success">
|
||||||
<span className="font-semibold">Completed in {formatDuration(job.started_at, job.finished_at)}</span>
|
<span className="font-semibold">{t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||||
{job.stats_json && (
|
{isMetadataBatch && batchReport && (
|
||||||
<span className="ml-2 text-success/80">
|
<span className="ml-2 text-success/80">
|
||||||
— {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
— {batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
|
||||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warnings`}
|
|
||||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} errors`}
|
|
||||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} thumbnails`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!job.stats_json && isThumbnailOnly && job.total_files != null && (
|
{isMetadataRefresh && refreshReport && (
|
||||||
<span className="ml-2 text-success/80">
|
<span className="ml-2 text-success/80">
|
||||||
— {job.processed_files ?? job.total_files} thumbnails generated
|
— {refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isMetadataBatch && !isMetadataRefresh && job.stats_json && (
|
||||||
|
<span className="ml-2 text-success/80">
|
||||||
|
— {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||||
|
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||||
|
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||||
|
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
|
||||||
|
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isMetadataBatch && !isMetadataRefresh && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||||
|
<span className="ml-2 text-success/80">
|
||||||
|
— {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,9 +265,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-sm text-destructive">
|
<div className="text-sm text-destructive">
|
||||||
<span className="font-semibold">Job failed</span>
|
<span className="font-semibold">{t("jobDetail.jobFailed")}</span>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<span className="ml-2 text-destructive/80">after {formatDuration(job.started_at, job.finished_at)}</span>
|
<span className="ml-2 text-destructive/80">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||||
)}
|
)}
|
||||||
{job.error_opt && (
|
{job.error_opt && (
|
||||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
||||||
@@ -222,9 +283,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
<span className="font-semibold">Cancelled</span>
|
<span className="font-semibold">{t("jobDetail.cancelled")}</span>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<span className="ml-2">after {formatDuration(job.started_at, job.finished_at)}</span>
|
<span className="ml-2">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +295,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>{t("jobDetail.overview")}</CardTitle>
|
||||||
{typeInfo.description && (
|
{typeInfo.description && (
|
||||||
<CardDescription>{typeInfo.description}</CardDescription>
|
<CardDescription>{typeInfo.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
@@ -245,23 +306,23 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted-foreground">Type</span>
|
<span className="text-sm text-muted-foreground">{t("jobsList.type")}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<JobTypeBadge type={job.type} />
|
<JobTypeBadge type={job.type} />
|
||||||
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
|
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted-foreground">Status</span>
|
<span className="text-sm text-muted-foreground">{t("jobsList.status")}</span>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
||||||
<span className="text-sm text-muted-foreground">Library</span>
|
<span className="text-sm text-muted-foreground">{t("jobDetail.library")}</span>
|
||||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || t("jobDetail.allLibraries")}</span>
|
||||||
</div>
|
</div>
|
||||||
{job.book_id && (
|
{job.book_id && (
|
||||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
||||||
<span className="text-sm text-muted-foreground">Book</span>
|
<span className="text-sm text-muted-foreground">{t("jobDetail.book")}</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/books/${job.book_id}`}
|
href={`/books/${job.book_id}`}
|
||||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
||||||
@@ -272,7 +333,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted-foreground">Duration</span>
|
<span className="text-sm text-muted-foreground">{t("jobsList.duration")}</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -284,7 +345,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{/* Timeline Card */}
|
{/* Timeline Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Timeline</CardTitle>
|
<CardTitle>{t("jobDetail.timeline")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -296,8 +357,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Created</span>
|
<span className="text-sm font-medium text-foreground">{t("jobDetail.created")}</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString(locale)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,15 +367,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Phase 1 — Discovery</span>
|
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase1")}</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||||
Duration: {formatDuration(job.started_at, job.phase2_started_at)}
|
{t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
· {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
· {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warn`}
|
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -329,12 +390,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extracting pages</span>
|
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase2a")}</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString(locale)}</p>
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||||
Duration: {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
{t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
|
||||||
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">· in progress</span>
|
<span className="text-muted-foreground font-normal ml-1">· {t("jobDetail.inProgress")}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,26 +410,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}
|
{isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
|
||||||
</p>
|
</p>
|
||||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||||
Duration: {formatDuration(
|
{t("jobDetail.duration", { duration: formatDuration(
|
||||||
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||||
job.finished_at ?? null
|
job.finished_at ?? null
|
||||||
)}
|
) })}
|
||||||
{job.total_files != null && job.total_files > 0 && (
|
{job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
· {job.processed_files ?? job.total_files} thumbnails
|
· {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!job.finished_at && isThumbnailPhase && (
|
{!job.finished_at && isThumbnailPhase && (
|
||||||
<span className="text-xs text-muted-foreground">in progress</span>
|
<span className="text-xs text-muted-foreground">{t("jobDetail.inProgress")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,8 +442,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Started</span>
|
<span className="text-sm font-medium text-foreground">{t("jobDetail.started")}</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +453,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">Waiting to start…</span>
|
<span className="text-sm font-medium text-foreground">{t("jobDetail.pendingStart")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -405,9 +466,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}
|
{isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString(locale)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -430,13 +491,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<StatBox
|
<StatBox
|
||||||
value={job.processed_files ?? 0}
|
value={job.processed_files ?? 0}
|
||||||
label={isThumbnailOnly || isPhase2 ? "Generated" : "Processed"}
|
label={isThumbnailOnly || isPhase2 ? t("jobDetail.generated") : t("jobDetail.processed")}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||||
<StatBox
|
<StatBox
|
||||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||||
label="Remaining"
|
label={t("jobDetail.remaining")}
|
||||||
variant={isCompleted ? "default" : "warning"}
|
variant={isCompleted ? "default" : "warning"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,7 +505,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Current file</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">{t("jobDetail.currentFile")}</span>
|
||||||
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -453,10 +514,10 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Index Statistics — index jobs only */}
|
{/* Index Statistics — index jobs only */}
|
||||||
{job.stats_json && !isThumbnailOnly && (
|
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Index statistics</CardTitle>
|
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
@@ -466,11 +527,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||||
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
<StatBox value={job.stats_json.scanned_files} label={t("jobDetail.scanned")} variant="success" />
|
||||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
<StatBox value={job.stats_json.indexed_files} label={t("jobDetail.indexed")} variant="primary" />
|
||||||
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
<StatBox value={job.stats_json.removed_files} label={t("jobDetail.removed")} variant="warning" />
|
||||||
<StatBox value={job.stats_json.warnings ?? 0} label="Warnings" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
<StatBox value={job.stats_json.warnings ?? 0} label={t("jobDetail.warnings")} variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
<StatBox value={job.stats_json.errors} label={t("jobDetail.errors")} variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -480,7 +541,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Thumbnail statistics</CardTitle>
|
<CardTitle>{t("jobDetail.thumbnailStats")}</CardTitle>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
@@ -490,26 +551,244 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<StatBox value={job.processed_files ?? job.total_files} label="Generated" variant="success" />
|
<StatBox value={job.processed_files ?? job.total_files} label={t("jobDetail.generated")} variant="success" />
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Metadata batch report */}
|
||||||
|
{isMetadataBatch && batchReport && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("jobDetail.batchReport")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
<StatBox value={batchReport.auto_matched} label={t("jobDetail.autoMatched")} variant="success" />
|
||||||
|
<StatBox value={batchReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
|
||||||
|
<StatBox value={batchReport.no_results} label={t("jobDetail.noResults")} />
|
||||||
|
<StatBox value={batchReport.too_many_results} label={t("jobDetail.tooManyResults")} variant="warning" />
|
||||||
|
<StatBox value={batchReport.low_confidence} label={t("jobDetail.lowConfidence")} variant="warning" />
|
||||||
|
<StatBox value={batchReport.errors} label={t("jobDetail.errors")} variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata refresh report */}
|
||||||
|
{isMetadataRefresh && refreshReport && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("jobDetail.refreshReport")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobDetail.refreshReportDesc", { count: String(refreshReport.total_links) })}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
|
||||||
|
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
||||||
|
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
||||||
|
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata refresh changes detail */}
|
||||||
|
{isMetadataRefresh && refreshReport && refreshReport.changes.length > 0 && (
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("jobDetail.refreshChanges")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobDetail.refreshChangesDesc", { count: String(refreshReport.changes.length) })}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||||
|
{refreshReport.changes.map((r, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
r.status === "updated" ? "bg-success/10 border-success/20" :
|
||||||
|
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
|
||||||
|
"bg-muted/50 border-border/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{job.library_id ? (
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
|
||||||
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
|
>
|
||||||
|
{r.series_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-muted-foreground">{r.provider}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
|
||||||
|
r.status === "updated" ? "bg-success/20 text-success" :
|
||||||
|
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{r.status === "updated" ? t("jobDetail.refreshed") :
|
||||||
|
r.status === "error" ? t("common.error") :
|
||||||
|
t("jobDetail.unchanged")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.error && (
|
||||||
|
<p className="text-xs text-destructive/80 mt-1">{r.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series field changes */}
|
||||||
|
{r.series_changes.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">{t("metadata.seriesLabel")}</span>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{r.series_changes.map((c, ci) => (
|
||||||
|
<div key={ci} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
|
||||||
|
<span className="text-muted-foreground line-through truncate max-w-[200px]" title={String(c.old ?? "—")}>
|
||||||
|
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old)) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-success shrink-0">→</span>
|
||||||
|
<span className="text-success truncate max-w-[200px]" title={String(c.new ?? "—")}>
|
||||||
|
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new)) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Book field changes */}
|
||||||
|
{r.book_changes.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">
|
||||||
|
{t("metadata.booksLabel")} ({r.book_changes.length})
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
{r.book_changes.map((b, bi) => (
|
||||||
|
<div key={bi} className="pl-2 border-l-2 border-border/60">
|
||||||
|
<Link
|
||||||
|
href={`/books/${b.book_id}`}
|
||||||
|
className="text-xs text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{b.volume != null && <span className="text-muted-foreground mr-1">T.{b.volume}</span>}
|
||||||
|
{b.title}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-0.5 space-y-0.5">
|
||||||
|
{b.changes.map((c, ci) => (
|
||||||
|
<div key={ci} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
|
||||||
|
<span className="text-muted-foreground line-through truncate max-w-[150px]" title={String(c.old ?? "—")}>
|
||||||
|
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old).substring(0, 60)) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-success shrink-0">→</span>
|
||||||
|
<span className="text-success truncate max-w-[150px]" title={String(c.new ?? "—")}>
|
||||||
|
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new).substring(0, 60)) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata batch results */}
|
||||||
|
{isMetadataBatch && batchResults.length > 0 && (
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(batchResults.length) })}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||||
|
{batchResults.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
r.status === "auto_matched" ? "bg-success/10 border-success/20" :
|
||||||
|
r.status === "already_linked" ? "bg-primary/10 border-primary/20" :
|
||||||
|
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
|
||||||
|
"bg-muted/50 border-border/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{job.library_id ? (
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
|
||||||
|
className="font-medium text-sm text-primary hover:underline truncate"
|
||||||
|
>
|
||||||
|
{r.series_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
|
||||||
|
r.status === "auto_matched" ? "bg-success/20 text-success" :
|
||||||
|
r.status === "already_linked" ? "bg-primary/20 text-primary" :
|
||||||
|
r.status === "no_results" ? "bg-muted text-muted-foreground" :
|
||||||
|
r.status === "too_many_results" ? "bg-amber-500/15 text-amber-600" :
|
||||||
|
r.status === "low_confidence" ? "bg-amber-500/15 text-amber-600" :
|
||||||
|
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{r.status === "auto_matched" ? t("jobDetail.autoMatched") :
|
||||||
|
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
|
||||||
|
r.status === "no_results" ? t("jobDetail.noResults") :
|
||||||
|
r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
|
||||||
|
r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
|
||||||
|
r.status === "error" ? t("common.error") :
|
||||||
|
r.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
|
{r.provider_used && (
|
||||||
|
<span>{r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}</span>
|
||||||
|
)}
|
||||||
|
{r.candidates_count > 0 && (
|
||||||
|
<span>{r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}</span>
|
||||||
|
)}
|
||||||
|
{r.best_confidence != null && (
|
||||||
|
<span>{Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.best_candidate_json && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{r.error_message && (
|
||||||
|
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File errors */}
|
{/* File errors */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>File errors ({errors.length})</CardTitle>
|
<CardTitle>{t("jobDetail.fileErrors", { count: String(errors.length) })}</CardTitle>
|
||||||
<CardDescription>Errors encountered while processing individual files</CardDescription>
|
<CardDescription>{t("jobDetail.fileErrorsDesc")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||||
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
||||||
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
||||||
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
|
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString(locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, IndexJobDto, LibraryDto } from "../../lib/api";
|
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||||
import { JobsList } from "../components/JobsList";
|
import { JobsList } from "../components/JobsList";
|
||||||
import { Card, CardHeader, CardTitle, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
|
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
|
||||||
const { highlight } = await searchParams;
|
const { highlight } = await searchParams;
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const [jobs, libraries] = await Promise.all([
|
const [jobs, libraries] = await Promise.all([
|
||||||
listJobs().catch(() => [] as IndexJobDto[]),
|
listJobs().catch(() => [] as IndexJobDto[]),
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||||
@@ -31,6 +33,14 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerRescan(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
const result = await rebuildIndex(libraryId || undefined, false, true);
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function triggerThumbnailsRebuild(formData: FormData) {
|
async function triggerThumbnailsRebuild(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
@@ -47,6 +57,67 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerMetadataBatch(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
if (libraryId) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await startMetadataBatch(libraryId);
|
||||||
|
} catch {
|
||||||
|
// Library may have metadata disabled — ignore silently
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
} else {
|
||||||
|
// All libraries — skip those with metadata disabled
|
||||||
|
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
|
||||||
|
let lastId: string | undefined;
|
||||||
|
for (const lib of allLibraries) {
|
||||||
|
if (lib.metadata_provider === "none") continue;
|
||||||
|
try {
|
||||||
|
const result = await startMetadataBatch(lib.id);
|
||||||
|
if (result.status !== "already_running") lastId = result.id;
|
||||||
|
} catch {
|
||||||
|
// Library may have metadata disabled or other issue — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerMetadataRefresh(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
if (libraryId) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await startMetadataRefresh(libraryId);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
} else {
|
||||||
|
// All libraries — skip those with metadata disabled
|
||||||
|
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
|
||||||
|
let lastId: string | undefined;
|
||||||
|
for (const lib of allLibraries) {
|
||||||
|
if (lib.metadata_provider === "none") continue;
|
||||||
|
try {
|
||||||
|
const result = await startMetadataRefresh(lib.id);
|
||||||
|
if (result.status !== "already_running") lastId = result.id;
|
||||||
|
} catch {
|
||||||
|
// Library may have metadata disabled or no approved links — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -54,52 +125,136 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Index Jobs
|
{t("jobs.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Queue New Job</CardTitle>
|
<CardTitle>{t("jobs.startJob")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobs.startJobDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form>
|
<form>
|
||||||
<FormRow>
|
<div className="mb-6">
|
||||||
<FormField className="flex-1 max-w-xs">
|
<FormField className="max-w-xs">
|
||||||
<FormSelect name="library_id" defaultValue="">
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option value="">All libraries</option>
|
<option value="">{t("jobs.allLibraries")}</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex flex-wrap gap-2">
|
</div>
|
||||||
<Button type="submit" formAction={triggerRebuild}>
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
{/* Indexation group */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Rebuild
|
{t("jobs.groupIndexation")}
|
||||||
</Button>
|
</div>
|
||||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
<div className="space-y-2">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button type="submit" formAction={triggerRebuild}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||||
</svg>
|
<div className="flex items-center gap-2">
|
||||||
Full Rebuild
|
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
</svg>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span className="font-medium text-sm text-foreground">{t("jobs.rebuild")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
|
||||||
|
</button>
|
||||||
|
<button type="submit" formAction={triggerRescan}
|
||||||
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-foreground">{t("jobs.rescan")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rescanShort")}</p>
|
||||||
|
</button>
|
||||||
|
<button type="submit" formAction={triggerFullRebuild}
|
||||||
|
className="w-full text-left rounded-lg border border-destructive/30 bg-destructive/5 p-3 hover:bg-destructive/10 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-destructive">{t("jobs.fullRebuild")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails group */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Generate thumbnails
|
{t("jobs.groupThumbnails")}
|
||||||
</Button>
|
</div>
|
||||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
<div className="space-y-2">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button type="submit" formAction={triggerThumbnailsRebuild}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||||
</svg>
|
<div className="flex items-center gap-2">
|
||||||
Regenerate thumbnails
|
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-foreground">{t("jobs.generateThumbnails")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.generateThumbnailsShort")}</p>
|
||||||
|
</button>
|
||||||
|
<button type="submit" formAction={triggerThumbnailsRegenerate}
|
||||||
|
className="w-full text-left rounded-lg border border-warning/30 bg-warning/5 p-3 hover:bg-warning/10 transition-colors group cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-warning">{t("jobs.regenerateThumbnails")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.regenerateThumbnailsShort")}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormRow>
|
|
||||||
|
{/* Metadata group */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
{t("jobs.groupMetadata")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="submit" formAction={triggerMetadataBatch}
|
||||||
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-foreground">{t("jobs.batchMetadata")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.batchMetadataShort")}</p>
|
||||||
|
</button>
|
||||||
|
<button type="submit" formAction={triggerMetadataRefresh}
|
||||||
|
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm text-foreground">{t("jobs.refreshMetadata")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.refreshMetadataShort")}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,32 +8,40 @@ import { ThemeToggle } from "./theme-toggle";
|
|||||||
import { JobsIndicator } from "./components/JobsIndicator";
|
import { JobsIndicator } from "./components/JobsIndicator";
|
||||||
import { NavIcon, Icon } from "./components/ui";
|
import { NavIcon, Icon } from "./components/ui";
|
||||||
import { MobileNav } from "./components/MobileNav";
|
import { MobileNav } from "./components/MobileNav";
|
||||||
|
import { LocaleProvider } from "../lib/i18n/context";
|
||||||
|
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
|
||||||
|
import type { TranslationKey } from "../lib/i18n/fr";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "StripStream Backoffice",
|
title: "StripStream Backoffice",
|
||||||
description: "Backoffice administration for StripStream Librarian"
|
description: "Administration backoffice pour StripStream Librarian"
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
label: string;
|
labelKey: TranslationKey;
|
||||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||||
{ href: "/books", label: "Books", icon: "books" },
|
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||||
{ href: "/series", label: "Series", icon: "series" },
|
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||||
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||||
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||||
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||||
|
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const locale = await getServerLocale();
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<LocaleProvider initialLocale={locale}>
|
||||||
{/* Header avec effet glassmorphism */}
|
{/* Header avec effet glassmorphism */}
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
@@ -54,7 +62,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
StripStream
|
StripStream
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
||||||
backoffice
|
{t("common.backoffice")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -63,9 +71,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink key={item.href} href={item.href} title={item.label}>
|
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||||
<NavIcon name={item.icon} />
|
<NavIcon name={item.icon} />
|
||||||
<span className="ml-2 hidden lg:inline">{item.label}</span>
|
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +84,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
title="Settings"
|
title={t("nav.settings")}
|
||||||
>
|
>
|
||||||
<Icon name="settings" size="md" />
|
<Icon name="settings" size="md" />
|
||||||
</Link>
|
</Link>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<MobileNav navItems={navItems} />
|
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -91,6 +99,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
</LocaleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
|||||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "../../../components/ui";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default async function LibraryBooksPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
|
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
|
||||||
@@ -38,14 +40,14 @@ export default async function LibraryBooksPage({
|
|||||||
coverUrl: getBookCoverUrl(book.id)
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
const seriesDisplayName = series === "unclassified" ? t("books.unclassified") : (series ?? "");
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LibrarySubPageHeader
|
<LibrarySubPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
title={series ? t("libraryBooks.booksOfSeries", { series: seriesDisplayName }) : t("libraryBooks.allBooks")}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
@@ -53,9 +55,9 @@ export default async function LibraryBooksPage({
|
|||||||
}
|
}
|
||||||
iconColor="text-success"
|
iconColor="text-success"
|
||||||
filterInfo={series ? {
|
filterInfo={series ? {
|
||||||
label: `Showing books from series "${seriesDisplayName}"`,
|
label: t("libraryBooks.filterLabel", { series: seriesDisplayName }),
|
||||||
clearHref: `/libraries/${id}/books`,
|
clearHref: `/libraries/${id}/books`,
|
||||||
clearLabel: "View all books"
|
clearLabel: t("libraryBooks.viewAll")
|
||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ export default async function LibraryBooksPage({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
<EmptyState message={series ? t("libraryBooks.noBooksInSeries", { series: seriesDisplayName }) : t("libraryBooks.noBooks")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, BookDto, SeriesMetadataDto } from "../../../../../lib/api";
|
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
import nextDynamic from "next/dynamic";
|
||||||
import { OffsetPagination } from "../../../../components/ui";
|
import { OffsetPagination } from "../../../../components/ui";
|
||||||
|
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EditSeriesForm = nextDynamic(
|
||||||
|
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
|
||||||
|
);
|
||||||
|
const MetadataSearchModal = nextDynamic(
|
||||||
|
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
|
||||||
|
);
|
||||||
|
const ProwlarrSearchModal = nextDynamic(
|
||||||
|
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
||||||
|
);
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -18,13 +30,14 @@ export default async function SeriesDetailPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { id, name } = await params;
|
const { id, name } = await params;
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
||||||
|
|
||||||
const seriesName = decodeURIComponent(name);
|
const seriesName = decodeURIComponent(name);
|
||||||
|
|
||||||
const [library, booksPage, seriesMeta] = await Promise.all([
|
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
|
||||||
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
@@ -33,8 +46,15 @@ export default async function SeriesDetailPage({
|
|||||||
limit,
|
limit,
|
||||||
})),
|
})),
|
||||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||||
|
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
|
||||||
|
let missingData: MissingBooksDto | null = null;
|
||||||
|
if (existingLink && existingLink.status === "approved") {
|
||||||
|
missingData = await getMissingBooks(existingLink.id).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!library) {
|
if (!library) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -46,7 +66,7 @@ export default async function SeriesDetailPage({
|
|||||||
|
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
||||||
const displayName = seriesName === "unclassified" ? "Non classifié" : seriesName;
|
const displayName = seriesName === "unclassified" ? t("books.unclassified") : seriesName;
|
||||||
|
|
||||||
// Use first book cover as series cover
|
// Use first book cover as series cover
|
||||||
const coverBookId = booksPage.items[0]?.id;
|
const coverBookId = booksPage.items[0]?.id;
|
||||||
@@ -59,7 +79,7 @@ export default async function SeriesDetailPage({
|
|||||||
href="/libraries"
|
href="/libraries"
|
||||||
className="text-muted-foreground hover:text-primary transition-colors"
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
Libraries
|
{t("nav.libraries")}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
<Link
|
<Link
|
||||||
@@ -79,10 +99,10 @@ export default async function SeriesDetailPage({
|
|||||||
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(coverBookId)}
|
src={getBookCoverUrl(coverBookId)}
|
||||||
alt={`Cover of ${displayName}`}
|
alt={t("books.coverOf", { name: displayName })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="160px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,12 +111,25 @@ export default async function SeriesDetailPage({
|
|||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
|
||||||
|
|
||||||
{seriesMeta && seriesMeta.authors.length > 0 && (
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
|
{seriesMeta && seriesMeta.authors.length > 0 && (
|
||||||
)}
|
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
{seriesMeta?.status && (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
seriesMeta.status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||||
|
seriesMeta.status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||||
|
seriesMeta.status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||||
|
seriesMeta.status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{seriesMeta?.description && (
|
{seriesMeta?.description && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{seriesMeta.description}</p>
|
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
@@ -110,14 +143,14 @@ export default async function SeriesDetailPage({
|
|||||||
)}
|
)}
|
||||||
{((seriesMeta && seriesMeta.publishers.length > 0) || seriesMeta?.start_year) && <span className="w-px h-4 bg-border" />}
|
{((seriesMeta && seriesMeta.publishers.length > 0) || seriesMeta?.start_year) && <span className="w-px h-4 bg-border" />}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
|
<span className="font-semibold text-foreground">{booksPage.total}</span> {t("dashboard.books").toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-px h-4 bg-border" />
|
<span className="w-px h-4 bg-border" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{booksReadCount}</span>/{booksPage.total} lu{booksPage.total !== 1 ? "s" : ""}
|
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Reading progress bar */}
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -126,6 +159,22 @@ export default async function SeriesDetailPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collection progress bar (owned / expected) */}
|
||||||
|
{missingData && missingData.total_external > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="w-px h-4 bg-border" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{booksPage.total}/{missingData.total_external} — {t("series.missingCount", { count: missingData.missing_count, plural: missingData.missing_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
<div className="w-[150px] h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.round((booksPage.total / missingData.total_external) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -143,6 +192,19 @@ export default async function SeriesDetailPage({
|
|||||||
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
|
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
|
||||||
currentDescription={seriesMeta?.description ?? null}
|
currentDescription={seriesMeta?.description ?? null}
|
||||||
currentStartYear={seriesMeta?.start_year ?? null}
|
currentStartYear={seriesMeta?.start_year ?? null}
|
||||||
|
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
|
||||||
|
currentStatus={seriesMeta?.status ?? null}
|
||||||
|
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
||||||
|
/>
|
||||||
|
<ProwlarrSearchModal
|
||||||
|
seriesName={seriesName}
|
||||||
|
missingBooks={missingData?.missing_books ?? null}
|
||||||
|
/>
|
||||||
|
<MetadataSearchModal
|
||||||
|
libraryId={id}
|
||||||
|
seriesName={seriesName}
|
||||||
|
existingLink={existingLink}
|
||||||
|
initialMissing={missingData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +222,7 @@ export default async function SeriesDetailPage({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message="Aucun livre dans cette série" />
|
<EmptyState message={t("librarySeries.noBooksInSeries")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "../../../components/ui";
|
||||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||||
|
import { SeriesFilters } from "../../../components/SeriesFilters";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
|
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,13 +18,17 @@ export default async function LibrarySeriesPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
||||||
|
const hasMissing = searchParamsAwaited.has_missing === "true";
|
||||||
|
|
||||||
const [library, seriesPage] = await Promise.all([
|
const [library, seriesPage, dbStatuses] = await Promise.all([
|
||||||
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||||
fetchSeries(id, page, limit).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto)
|
fetchSeries(id, page, limit, seriesStatus, hasMissing).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto),
|
||||||
|
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!library) {
|
if (!library) {
|
||||||
@@ -32,11 +38,23 @@ export default async function LibrarySeriesPage({
|
|||||||
const series = seriesPage.items;
|
const series = seriesPage.items;
|
||||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||||
|
|
||||||
|
const KNOWN_STATUSES: Record<string, string> = {
|
||||||
|
ongoing: t("seriesStatus.ongoing"),
|
||||||
|
ended: t("seriesStatus.ended"),
|
||||||
|
hiatus: t("seriesStatus.hiatus"),
|
||||||
|
cancelled: t("seriesStatus.cancelled"),
|
||||||
|
upcoming: t("seriesStatus.upcoming"),
|
||||||
|
};
|
||||||
|
const seriesStatusOptions = [
|
||||||
|
{ value: "", label: t("seriesStatus.allStatuses") },
|
||||||
|
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LibrarySubPageHeader
|
<LibrarySubPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
title="Series"
|
title={t("series.title")}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
@@ -45,6 +63,13 @@ export default async function LibrarySeriesPage({
|
|||||||
iconColor="text-primary"
|
iconColor="text-primary"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SeriesFilters
|
||||||
|
basePath={`/libraries/${id}/series`}
|
||||||
|
currentSeriesStatus={seriesStatus}
|
||||||
|
currentHasMissing={hasMissing}
|
||||||
|
seriesStatusOptions={seriesStatusOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
{series.length > 0 ? (
|
{series.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
@@ -58,19 +83,19 @@ export default async function LibrarySeriesPage({
|
|||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? 's' : ''}
|
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
@@ -78,6 +103,24 @@ export default async function LibrarySeriesPage({
|
|||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||||
|
{s.series_status && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
|
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||||
|
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||||
|
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||||
|
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{KNOWN_STATUSES[s.series_status] || s.series_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.missing_count != null && s.missing_count > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||||
|
{t("series.missingCount", { count: String(s.missing_count) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,7 +136,7 @@ export default async function LibrarySeriesPage({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<p>No series found in this library</p>
|
<p>{t("librarySeries.noSeries")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
|
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
|
||||||
|
import type { TranslationKey } from "../../lib/i18n/fr";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
import { LibraryActions } from "../components/LibraryActions";
|
import { LibraryActions } from "../components/LibraryActions";
|
||||||
import { LibraryForm } from "../components/LibraryForm";
|
import { LibraryForm } from "../components/LibraryForm";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
Button, Badge
|
Button, Badge
|
||||||
@@ -10,13 +14,13 @@ import {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function formatNextScan(nextScanAt: string | null): string {
|
function formatNextScan(nextScanAt: string | null, imminentLabel: string): string {
|
||||||
if (!nextScanAt) return "-";
|
if (!nextScanAt) return "-";
|
||||||
const date = new Date(nextScanAt);
|
const date = new Date(nextScanAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
const diff = date.getTime() - now.getTime();
|
||||||
|
|
||||||
if (diff < 0) return "Due now";
|
if (diff < 0) return imminentLabel;
|
||||||
if (diff < 60000) return "< 1 min";
|
if (diff < 60000) return "< 1 min";
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||||
@@ -24,24 +28,19 @@ function formatNextScan(nextScanAt: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function LibrariesPage() {
|
export default async function LibrariesPage() {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const [libraries, folders] = await Promise.all([
|
const [libraries, folders] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
listFolders().catch(() => [] as FolderItem[])
|
listFolders().catch(() => [] as FolderItem[])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seriesCounts = await Promise.all(
|
const thumbnailMap = new Map(
|
||||||
libraries.map(async (lib) => {
|
libraries.map(lib => [
|
||||||
try {
|
lib.id,
|
||||||
const seriesPage = await fetchSeries(lib.id);
|
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
|
||||||
return { id: lib.id, count: seriesPage.items.length };
|
])
|
||||||
} catch {
|
|
||||||
return { id: lib.id, count: 0 };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
|
|
||||||
|
|
||||||
async function addLibrary(formData: FormData) {
|
async function addLibrary(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
@@ -59,22 +58,6 @@ export default async function LibrariesPage() {
|
|||||||
revalidatePath("/libraries");
|
revalidatePath("/libraries");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanLibraryAction(formData: FormData) {
|
|
||||||
"use server";
|
|
||||||
const id = formData.get("id") as string;
|
|
||||||
await scanLibrary(id);
|
|
||||||
revalidatePath("/libraries");
|
|
||||||
revalidatePath("/jobs");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanLibraryFullAction(formData: FormData) {
|
|
||||||
"use server";
|
|
||||||
const id = formData.get("id") as string;
|
|
||||||
await scanLibrary(id, true);
|
|
||||||
revalidatePath("/libraries");
|
|
||||||
revalidatePath("/jobs");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -82,15 +65,15 @@ export default async function LibrariesPage() {
|
|||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Libraries
|
{t("libraries.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Library Form */}
|
{/* Add Library Form */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add New Library</CardTitle>
|
<CardTitle>{t("libraries.addLibrary")}</CardTitle>
|
||||||
<CardDescription>Create a new library from an existing folder</CardDescription>
|
<CardDescription>{t("libraries.addLibraryDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||||
@@ -100,88 +83,139 @@ export default async function LibrariesPage() {
|
|||||||
{/* Libraries Grid */}
|
{/* Libraries Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{libraries.map((lib) => {
|
{libraries.map((lib) => {
|
||||||
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
const thumbnails = thumbnailMap.get(lib.id) || [];
|
||||||
return (
|
return (
|
||||||
<Card key={lib.id} className="flex flex-col">
|
<Card key={lib.id} className="flex flex-col overflow-hidden">
|
||||||
|
{/* Thumbnail fan */}
|
||||||
|
{thumbnails.length > 0 ? (
|
||||||
|
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
|
||||||
|
<Image
|
||||||
|
src={thumbnails[0]}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-cover blur-xl scale-110 opacity-40"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-end justify-center">
|
||||||
|
{thumbnails.map((url, i) => {
|
||||||
|
const count = thumbnails.length;
|
||||||
|
const mid = (count - 1) / 2;
|
||||||
|
const angle = (i - mid) * 12;
|
||||||
|
const radius = 220;
|
||||||
|
const rad = ((angle - 90) * Math.PI) / 180;
|
||||||
|
const cx = Math.cos(rad) * radius;
|
||||||
|
const cy = Math.sin(rad) * radius;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
width={96}
|
||||||
|
height={144}
|
||||||
|
className="absolute object-cover shadow-lg"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
zIndex: count - Math.abs(Math.round(i - mid)),
|
||||||
|
bottom: '-185px',
|
||||||
|
}}
|
||||||
|
sizes="96px"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 bg-muted/10" />
|
||||||
|
)}
|
||||||
|
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<LibraryActions
|
||||||
|
libraryId={lib.id}
|
||||||
|
monitorEnabled={lib.monitor_enabled}
|
||||||
|
scanMode={lib.scan_mode}
|
||||||
|
watcherEnabled={lib.watcher_enabled}
|
||||||
|
metadataProvider={lib.metadata_provider}
|
||||||
|
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||||
|
metadataRefreshMode={lib.metadata_refresh_mode}
|
||||||
|
/>
|
||||||
|
<form>
|
||||||
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" formAction={removeLibrary} className="text-muted-foreground hover:text-destructive">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<LibraryActions
|
|
||||||
libraryId={lib.id}
|
|
||||||
monitorEnabled={lib.monitor_enabled}
|
|
||||||
scanMode={lib.scan_mode}
|
|
||||||
watcherEnabled={lib.watcher_enabled}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground break-all">{lib.root_path}</code>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 pt-0">
|
<CardContent className="flex-1 pt-0">
|
||||||
{/* Path */}
|
|
||||||
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{lib.root_path}</code>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.id}/books`}
|
href={`/libraries/${lib.id}/books`}
|
||||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||||
<span className="text-xs text-muted-foreground">Books</span>
|
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.id}/series`}
|
href={`/libraries/${lib.id}/series`}
|
||||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
<span className="block text-2xl font-bold text-foreground">{lib.series_count}</span>
|
||||||
<span className="text-xs text-muted-foreground">Series</span>
|
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Configuration tags */}
|
||||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
|
lib.monitor_enabled
|
||||||
|
? 'bg-success/10 text-success'
|
||||||
|
: 'bg-muted/50 text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
<span className="text-[9px]">{lib.monitor_enabled ? '●' : '○'}</span>
|
||||||
|
{t("libraries.scanLabel", { mode: t(`monitoring.${lib.scan_mode}` as TranslationKey) })}
|
||||||
</span>
|
</span>
|
||||||
{lib.watcher_enabled && (
|
|
||||||
<span className="text-warning" title="File watcher active">⚡</span>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
)}
|
lib.watcher_enabled
|
||||||
{lib.monitor_enabled && lib.next_scan_at && (
|
? 'bg-warning/10 text-warning'
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
: 'bg-muted/50 text-muted-foreground'
|
||||||
Next: {formatNextScan(lib.next_scan_at)}
|
}`}>
|
||||||
|
<span>{lib.watcher_enabled ? '⚡' : '○'}</span>
|
||||||
|
<span>{t("libraries.watcherLabel")}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{lib.metadata_provider && lib.metadata_provider !== "none" && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 text-primary">
|
||||||
|
<ProviderIcon provider={lib.metadata_provider} size={11} />
|
||||||
|
{lib.metadata_provider.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{lib.metadata_refresh_mode !== "manual" && (
|
||||||
<div className="flex items-center gap-2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
|
||||||
<form className="flex-1">
|
{t("libraries.metaRefreshLabel", { mode: t(`monitoring.${lib.metadata_refresh_mode}` as TranslationKey) })}
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
</span>
|
||||||
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
|
)}
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
{lib.monitor_enabled && lib.next_scan_at && (
|
||||||
</svg>
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
|
||||||
Index
|
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
|
||||||
</Button>
|
</span>
|
||||||
</form>
|
)}
|
||||||
<form className="flex-1">
|
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
|
||||||
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Full
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<form>
|
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
|
||||||
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { fetchStats, StatsResponse } from "../lib/api";
|
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||||
|
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
|
||||||
|
import { PeriodToggle } from "./components/PeriodToggle";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getServerTranslations } from "../lib/i18n/server";
|
||||||
|
import type { TranslateFunction } from "../lib/i18n/dictionaries";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,95 +18,36 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(n: number): string {
|
function formatNumber(n: number, locale: string): string {
|
||||||
return n.toLocaleString("fr-FR");
|
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Donut chart via SVG
|
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
|
||||||
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
if (period === "month") {
|
||||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
// raw = "YYYY-MM"
|
||||||
|
const [y, m] = raw.split("-");
|
||||||
const radius = 40;
|
const d = new Date(Number(y), Number(m) - 1, 1);
|
||||||
const circumference = 2 * Math.PI * radius;
|
return d.toLocaleDateString(loc, { month: "short" });
|
||||||
let offset = 0;
|
}
|
||||||
|
if (period === "week") {
|
||||||
return (
|
// raw = "YYYY-MM-DD" (Monday of the week)
|
||||||
<div className="flex items-center gap-6">
|
const d = new Date(raw + "T00:00:00");
|
||||||
<svg viewBox="0 0 100 100" className="w-32 h-32 shrink-0">
|
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
|
||||||
{data.map((d, i) => {
|
}
|
||||||
const pct = d.value / total;
|
// day: raw = "YYYY-MM-DD"
|
||||||
const dashLength = pct * circumference;
|
const d = new Date(raw + "T00:00:00");
|
||||||
const currentOffset = offset;
|
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
|
||||||
offset += dashLength;
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={i}
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke={d.color}
|
|
||||||
strokeWidth="16"
|
|
||||||
strokeDasharray={`${dashLength} ${circumference - dashLength}`}
|
|
||||||
strokeDashoffset={-currentOffset}
|
|
||||||
transform="rotate(-90 50 50)"
|
|
||||||
className="transition-all duration-500"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-foreground text-[10px] font-bold">
|
|
||||||
{formatNumber(total)}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
|
||||||
{data.map((d, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
|
||||||
<span className="text-muted-foreground truncate">{d.label}</span>
|
|
||||||
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar chart via pure CSS
|
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||||
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
|
||||||
const max = Math.max(...data.map((d) => d.value), 1);
|
|
||||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-1.5 h-40">
|
|
||||||
{data.map((d, i) => (
|
|
||||||
<div key={i} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
|
||||||
<span className="text-[10px] text-muted-foreground font-medium">{d.value || ""}</span>
|
|
||||||
<div
|
|
||||||
className="w-full rounded-t-sm transition-all duration-500 min-h-[2px]"
|
|
||||||
style={{
|
|
||||||
height: `${(d.value / max) * 100}%`,
|
|
||||||
backgroundColor: color,
|
|
||||||
opacity: d.value === 0 ? 0.2 : 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
|
|
||||||
{d.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal progress bar for library breakdown
|
|
||||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium text-foreground truncate">{label}</span>
|
<span className="font-medium text-foreground truncate">{label}</span>
|
||||||
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || formatNumber(value)}</span>
|
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || value}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -113,10 +59,19 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const rawPeriod = searchParamsAwaited.period;
|
||||||
|
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
||||||
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
let stats: StatsResponse | null = null;
|
let stats: StatsResponse | null = null;
|
||||||
try {
|
try {
|
||||||
stats = await fetchStats();
|
stats = await fetchStats(period);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch stats:", e);
|
console.error("Failed to fetch stats:", e);
|
||||||
}
|
}
|
||||||
@@ -126,14 +81,14 @@ export default async function DashboardPage() {
|
|||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
||||||
<p className="text-lg text-muted-foreground">Unable to load statistics. Make sure the API is running.</p>
|
<p className="text-lg text-muted-foreground">{t("dashboard.loadError")}</p>
|
||||||
</div>
|
</div>
|
||||||
<QuickLinks />
|
<QuickLinks t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { overview, reading_status, by_format, by_language, by_library, top_series, additions_over_time } = stats;
|
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
|
||||||
|
|
||||||
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||||
const formatColors = [
|
const formatColors = [
|
||||||
@@ -142,7 +97,7 @@ export default async function DashboardPage() {
|
|||||||
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
|
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
|
||||||
];
|
];
|
||||||
|
|
||||||
const maxLibBooks = Math.max(...by_library.map((l) => l.book_count), 1);
|
const noDataLabel = t("common.noData");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
@@ -152,36 +107,128 @@ export default async function DashboardPage() {
|
|||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Dashboard
|
{t("dashboard.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2 max-w-2xl">
|
<p className="text-muted-foreground mt-2 max-w-2xl">
|
||||||
Overview of your comic collection. Manage your libraries, track your reading progress, and explore your books and series.
|
{t("dashboard.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overview stat cards */}
|
{/* Overview stat cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
<StatCard icon="book" label="Books" value={formatNumber(overview.total_books)} color="success" />
|
<StatCard icon="book" label={t("dashboard.books")} value={formatNumber(overview.total_books, locale)} color="success" />
|
||||||
<StatCard icon="series" label="Series" value={formatNumber(overview.total_series)} color="primary" />
|
<StatCard icon="series" label={t("dashboard.series")} value={formatNumber(overview.total_series, locale)} color="primary" />
|
||||||
<StatCard icon="library" label="Libraries" value={formatNumber(overview.total_libraries)} color="warning" />
|
<StatCard icon="library" label={t("dashboard.libraries")} value={formatNumber(overview.total_libraries, locale)} color="warning" />
|
||||||
<StatCard icon="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
|
<StatCard icon="pages" label={t("dashboard.pages")} value={formatNumber(overview.total_pages, locale)} color="primary" />
|
||||||
<StatCard icon="author" label="Authors" value={formatNumber(overview.total_authors)} color="success" />
|
<StatCard icon="author" label={t("dashboard.authors")} value={formatNumber(overview.total_authors, locale)} color="success" />
|
||||||
<StatCard icon="size" label="Total Size" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
<StatCard icon="size" label={t("dashboard.totalSize")} value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Currently reading + Recently read */}
|
||||||
|
{(currently_reading.length > 0 || recently_read.length > 0) && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Currently reading */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{currently_reading.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
|
{currently_reading.slice(0, 8).map((book) => {
|
||||||
|
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(book.book_id)}
|
||||||
|
alt={book.title}
|
||||||
|
width={40}
|
||||||
|
height={56}
|
||||||
|
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||||
|
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">{t("dashboard.pageProgress", { current: book.current_page, total: book.page_count })}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recently read */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recently_read.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
|
{recently_read.map((book) => (
|
||||||
|
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(book.book_id)}
|
||||||
|
alt={book.title}
|
||||||
|
width={40}
|
||||||
|
height={56}
|
||||||
|
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||||
|
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reading activity line chart */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||||
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RcAreaChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
|
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
||||||
|
color="hsl(142 60% 45%)"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Charts row */}
|
{/* Charts row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Reading status donut */}
|
{/* Reading status donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Reading Status</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
data={[
|
data={[
|
||||||
{ label: "Unread", value: reading_status.unread, color: readingColors[0] },
|
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
||||||
{ label: "In Progress", value: reading_status.reading, color: readingColors[1] },
|
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
||||||
{ label: "Read", value: reading_status.read, color: readingColors[2] },
|
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -190,12 +237,13 @@ export default async function DashboardPage() {
|
|||||||
{/* By format donut */}
|
{/* By format donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">By Format</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
data={by_format.slice(0, 6).map((f, i) => ({
|
data={by_format.slice(0, 6).map((f, i) => ({
|
||||||
label: (f.format || "Unknown").toUpperCase(),
|
name: (f.format || t("dashboard.unknown")).toUpperCase(),
|
||||||
value: f.count,
|
value: f.count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -206,12 +254,13 @@ export default async function DashboardPage() {
|
|||||||
{/* By library donut */}
|
{/* By library donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">By Library</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
data={by_library.slice(0, 6).map((l, i) => ({
|
data={by_library.slice(0, 6).map((l, i) => ({
|
||||||
label: l.library_name,
|
name: l.library_name,
|
||||||
value: l.book_count,
|
value: l.book_count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -220,94 +269,156 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second row */}
|
{/* Metadata row */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Monthly additions bar chart */}
|
{/* Series metadata coverage donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Books Added (Last 12 Months)</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BarChart
|
<RcDonutChart
|
||||||
data={additions_over_time.map((m) => ({
|
noDataLabel={noDataLabel}
|
||||||
label: m.month.slice(5), // "MM" from "YYYY-MM"
|
data={[
|
||||||
value: m.books_added,
|
{ name: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
|
||||||
}))}
|
{ name: t("dashboard.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
|
||||||
color="hsl(198 78% 37%)"
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Top series */}
|
{/* By provider donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Top Series</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<RcDonutChart
|
||||||
{top_series.slice(0, 8).map((s, i) => (
|
noDataLabel={noDataLabel}
|
||||||
<HorizontalBar
|
data={metadata.by_provider.map((p, i) => ({
|
||||||
key={i}
|
name: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||||
label={s.series}
|
value: p.count,
|
||||||
value={s.book_count}
|
color: formatColors[i % formatColors.length],
|
||||||
max={top_series[0]?.book_count || 1}
|
}))}
|
||||||
subLabel={`${s.read_count}/${s.book_count} read`}
|
/>
|
||||||
color="hsl(142 60% 45%)"
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
))}
|
|
||||||
{top_series.length === 0 && (
|
{/* Book metadata quality */}
|
||||||
<p className="text-muted-foreground text-sm text-center py-4">No series yet</p>
|
<Card hover={false}>
|
||||||
)}
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("dashboard.bookMetadata")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HorizontalBar
|
||||||
|
label={t("dashboard.withSummary")}
|
||||||
|
value={metadata.books_with_summary}
|
||||||
|
max={overview.total_books}
|
||||||
|
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_summary / overview.total_books) * 100)}%` : "0%"}
|
||||||
|
color="hsl(198 78% 37%)"
|
||||||
|
/>
|
||||||
|
<HorizontalBar
|
||||||
|
label={t("dashboard.withIsbn")}
|
||||||
|
value={metadata.books_with_isbn}
|
||||||
|
max={overview.total_books}
|
||||||
|
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_isbn / overview.total_books) * 100)}%` : "0%"}
|
||||||
|
color="hsl(280 60% 50%)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Libraries breakdown */}
|
{/* Libraries breakdown + Top series */}
|
||||||
{by_library.length > 0 && (
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{by_library.length > 0 && (
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RcStackedBar
|
||||||
|
data={by_library.map((lib) => ({
|
||||||
|
name: lib.library_name,
|
||||||
|
read: lib.read_count,
|
||||||
|
reading: lib.reading_count,
|
||||||
|
unread: lib.unread_count,
|
||||||
|
sizeLabel: formatBytes(lib.size_bytes),
|
||||||
|
}))}
|
||||||
|
labels={{
|
||||||
|
read: t("status.read"),
|
||||||
|
reading: t("status.reading"),
|
||||||
|
unread: t("status.unread"),
|
||||||
|
books: t("dashboard.books"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top series */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Libraries</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
<RcHorizontalBar
|
||||||
{by_library.map((lib, i) => (
|
noDataLabel={t("dashboard.noSeries")}
|
||||||
<div key={i} className="space-y-2">
|
data={top_series.slice(0, 8).map((s) => ({
|
||||||
<div className="flex justify-between items-baseline">
|
name: s.series,
|
||||||
<span className="font-medium text-foreground text-sm">{lib.library_name}</span>
|
value: s.book_count,
|
||||||
<span className="text-xs text-muted-foreground">{formatBytes(lib.size_bytes)}</span>
|
subLabel: t("dashboard.readCount", { read: s.read_count, total: s.book_count }),
|
||||||
</div>
|
}))}
|
||||||
<div className="h-3 bg-muted rounded-full overflow-hidden flex">
|
color="hsl(142 60% 45%)"
|
||||||
<div
|
/>
|
||||||
className="h-full transition-all duration-500"
|
|
||||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
|
||||||
title={`Read: ${lib.read_count}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full transition-all duration-500"
|
|
||||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
|
||||||
title={`In progress: ${lib.reading_count}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full transition-all duration-500"
|
|
||||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
|
||||||
title={`Unread: ${lib.unread_count}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
|
||||||
<span>{lib.book_count} books</span>
|
|
||||||
<span className="text-success">{lib.read_count} read</span>
|
|
||||||
<span className="text-warning">{lib.reading_count} in progress</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Additions line chart – full width */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||||
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RcAreaChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
|
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||||
|
color="hsl(198 78% 37%)"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Jobs over time – multi-line chart */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||||
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RcMultiLineChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
|
data={jobs_over_time.map((j) => ({
|
||||||
|
label: formatChartLabel(j.label, period, locale),
|
||||||
|
scan: j.scan,
|
||||||
|
rebuild: j.rebuild,
|
||||||
|
thumbnail: j.thumbnail,
|
||||||
|
other: j.other,
|
||||||
|
}))}
|
||||||
|
lines={[
|
||||||
|
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||||
|
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||||
|
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||||
|
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<QuickLinks />
|
<QuickLinks t={t} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,12 +456,12 @@ function StatCard({ icon, label, value, color }: { icon: string; label: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickLinks() {
|
function QuickLinks({ t }: { t: TranslateFunction }) {
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/libraries", label: "Libraries", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
{ href: "/libraries", label: t("nav.libraries"), bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
||||||
{ href: "/books", label: "Books", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
|
{ href: "/books", label: t("nav.books"), bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
|
||||||
{ href: "/series", label: "Series", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
{ href: "/series", label: t("nav.series"), bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
||||||
{ href: "/jobs", label: "Jobs", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
{ href: "/jobs", label: t("nav.jobs"), bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { fetchAllSeries, fetchLibraries, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||||
|
import { getServerTranslations } from "../../lib/i18n/server";
|
||||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,40 +14,73 @@ export default async function SeriesPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
|
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
||||||
|
const hasMissing = searchParamsAwaited.has_missing === "true";
|
||||||
|
const metadataProvider = typeof searchParamsAwaited.metadata_provider === "string" ? searchParamsAwaited.metadata_provider : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
const [libraries, seriesPage] = await Promise.all([
|
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort).catch(
|
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort, seriesStatus, hasMissing, metadataProvider).catch(
|
||||||
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
|
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const series = seriesPage.items;
|
const series = seriesPage.items;
|
||||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: "", label: "Title" },
|
{ value: "", label: t("books.sortTitle") },
|
||||||
{ value: "latest", label: "Latest added" },
|
{ value: "latest", label: t("books.sortLatest") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing || metadataProvider;
|
||||||
|
|
||||||
const libraryOptions = [
|
const libraryOptions = [
|
||||||
{ value: "", label: "All libraries" },
|
{ value: "", label: t("books.allLibraries") },
|
||||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: "", label: "All" },
|
{ value: "", label: t("common.all") },
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "unread", label: t("status.unread") },
|
||||||
{ value: "reading", label: "In progress" },
|
{ value: "reading", label: t("status.reading") },
|
||||||
{ value: "read", label: "Read" },
|
{ value: "read", label: t("status.read") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const KNOWN_STATUSES: Record<string, string> = {
|
||||||
|
ongoing: t("seriesStatus.ongoing"),
|
||||||
|
ended: t("seriesStatus.ended"),
|
||||||
|
hiatus: t("seriesStatus.hiatus"),
|
||||||
|
cancelled: t("seriesStatus.cancelled"),
|
||||||
|
upcoming: t("seriesStatus.upcoming"),
|
||||||
|
};
|
||||||
|
const seriesStatusOptions = [
|
||||||
|
{ value: "", label: t("seriesStatus.allStatuses") },
|
||||||
|
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingOptions = [
|
||||||
|
{ value: "", label: t("common.all") },
|
||||||
|
{ value: "true", label: t("series.missingBooks") },
|
||||||
|
];
|
||||||
|
|
||||||
|
const metadataOptions = [
|
||||||
|
{ value: "", label: t("series.metadataAll") },
|
||||||
|
{ value: "linked", label: t("series.metadataLinked") },
|
||||||
|
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||||
|
{ value: "google_books", label: "Google Books" },
|
||||||
|
{ value: "open_library", label: "Open Library" },
|
||||||
|
{ value: "comicvine", label: "ComicVine" },
|
||||||
|
{ value: "anilist", label: "AniList" },
|
||||||
|
{ value: "bedetheque", label: "Bédéthèque" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +90,7 @@ export default async function SeriesPage({
|
|||||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
</svg>
|
</svg>
|
||||||
Series
|
{t("series.title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,10 +99,13 @@ export default async function SeriesPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/series"
|
basePath="/series"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: t("series.reading"), options: statusOptions },
|
||||||
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
{ name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions },
|
||||||
|
{ name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions },
|
||||||
|
{ name: "metadata_provider", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||||
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -75,8 +113,8 @@ export default async function SeriesPage({
|
|||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{seriesPage.total} series
|
{seriesPage.total} {t("series.title").toLowerCase()}
|
||||||
{searchQuery && <> matching "{searchQuery}"</>}
|
{searchQuery && <> {t("series.matchingQuery")} "{searchQuery}"</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Series Grid */}
|
{/* Series Grid */}
|
||||||
@@ -97,19 +135,19 @@ export default async function SeriesPage({
|
|||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? "s" : ""}
|
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
@@ -117,6 +155,29 @@ export default async function SeriesPage({
|
|||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||||
|
{s.series_status && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
|
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||||
|
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||||
|
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||||
|
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{KNOWN_STATUSES[s.series_status] || s.series_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.missing_count != null && s.missing_count > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||||
|
{t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.metadata_provider && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-purple-500/15 text-purple-600 inline-flex items-center gap-0.5">
|
||||||
|
<ProviderIcon provider={s.metadata_provider} size={10} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -138,7 +199,7 @@ export default async function SeriesPage({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
{hasFilters ? "No series found matching your filters" : "No series available"}
|
{hasFilters ? t("series.noResults") : t("series.noSeries")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user