mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 |
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](./LICENSE)
|
||||
[](https://discord.gg/cfncTbJ2)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ pnpm tauri:dev
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/cfncTbJ2)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,17 +3,22 @@ Major Revisions:
|
||||
|
||||
Minor Revisions:
|
||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
- Adjustment in Settings for Theme Editor:
|
||||
- Patch Color-Picker to Work Properly
|
||||
- Moku Discord RPC
|
||||
- Write a better library for Discord RPC & Tauri
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
Priority Bugs:
|
||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||
- Investigate Zoom (Reader), Appears to have Cutoff, etc.
|
||||
- Fix Library Build not Updating
|
||||
- Check Auth System (Only Supports Basic-Auth)
|
||||
|
||||
|
||||
General/Misc Bugs:
|
||||
- Fix Highlightable Elements
|
||||
@@ -22,30 +27,17 @@ General/Misc Bugs:
|
||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||
|
||||
In-Progress:`
|
||||
|
||||
In-Progress:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
- Fix NSFW Parsing (Appears to not Work???)
|
||||
|
||||
- Check & Fix Zoom System
|
||||
- Incredibly zoomed in on Windows (Appears to work fine on 1440p)
|
||||
- Zoom Values are Incorrect
|
||||
- Global Zoom should only scale Reader UI, not Manga
|
||||
|
||||
- Fix Resume-from-Read
|
||||
- Start on Chapter 46 -> Go all the way to Chapter 47 (Page 28)
|
||||
- Results in Opening Chapter 46 to take to last page of Chapter (Cache not Cleared).
|
||||
- Add Event that if different chapter is opened, cache is cleared on all previous chapters.
|
||||
- Add into Settings
|
||||
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
|
||||
|
||||
|
||||
|
||||
Important Commands:
|
||||
cd ~/Projects/Manga/Moku
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||
|
||||
Testing:
|
||||
|
||||
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||
flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
- Fix Source Allow in Content (Doesn't even work)
|
||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
||||
path: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: fb01fc1a98499aeb5cf3e464c430a94c78ab1e68f15220ea8f95091f6ca593f2
|
||||
sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
version = "0.6.1";
|
||||
version = "0.7.0";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
|
||||
@@ -1952,14 +1952,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/hyper/hyper-1.8.1.crate",
|
||||
"sha256": "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11",
|
||||
"dest": "cargo/vendor/hyper-1.8.1"
|
||||
"url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
|
||||
"sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
|
||||
"dest": "cargo/vendor/hyper-1.9.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hyper-1.8.1",
|
||||
"contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hyper-1.9.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2355,14 +2355,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.92.crate",
|
||||
"sha256": "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995",
|
||||
"dest": "cargo/vendor/js-sys-0.3.92"
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate",
|
||||
"sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9",
|
||||
"dest": "cargo/vendor/js-sys-0.3.94"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.92",
|
||||
"contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.94",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3522,19 +3522,6 @@
|
||||
"dest": "cargo/vendor/pin-project-lite-0.2.17",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/pin-utils/pin-utils-0.1.0.crate",
|
||||
"sha256": "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184",
|
||||
"dest": "cargo/vendor/pin-utils-0.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/pin-utils-0.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -4617,14 +4604,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.0.crate",
|
||||
"sha256": "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98",
|
||||
"dest": "cargo/vendor/serde_spanned-1.1.0"
|
||||
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate",
|
||||
"sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26",
|
||||
"dest": "cargo/vendor/serde_spanned-1.1.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_spanned-1.1.0",
|
||||
"contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_spanned-1.1.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5670,14 +5657,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.0+spec-1.1.0.crate",
|
||||
"sha256": "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f",
|
||||
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.1+spec-1.1.0.crate",
|
||||
"sha256": "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7",
|
||||
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0",
|
||||
"contents": "{\"package\": \"3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5709,40 +5696,40 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.8+spec-1.1.0.crate",
|
||||
"sha256": "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.9+spec-1.1.0.crate",
|
||||
"sha256": "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0",
|
||||
"contents": "{\"package\": \"da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.0+spec-1.1.0.crate",
|
||||
"sha256": "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.1+spec-1.1.0.crate",
|
||||
"sha256": "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0",
|
||||
"contents": "{\"package\": \"39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.0+spec-1.1.0.crate",
|
||||
"sha256": "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed",
|
||||
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0"
|
||||
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.1+spec-1.1.0.crate",
|
||||
"sha256": "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db",
|
||||
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0",
|
||||
"contents": "{\"package\": \"756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -6203,66 +6190,66 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.115.crate",
|
||||
"sha256": "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.115"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate",
|
||||
"sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.117"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.115",
|
||||
"contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.117",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.65.crate",
|
||||
"sha256": "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate",
|
||||
"sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65",
|
||||
"contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.115.crate",
|
||||
"sha256": "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate",
|
||||
"sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115",
|
||||
"contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.115.crate",
|
||||
"sha256": "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate",
|
||||
"sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115",
|
||||
"contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.115.crate",
|
||||
"sha256": "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate",
|
||||
"sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115",
|
||||
"contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -6320,14 +6307,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.92.crate",
|
||||
"sha256": "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94",
|
||||
"dest": "cargo/vendor/web-sys-0.3.92"
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate",
|
||||
"sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a",
|
||||
"dest": "cargo/vendor/web-sys-0.3.94"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.92",
|
||||
"contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.94",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7347,14 +7334,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.0.crate",
|
||||
"sha256": "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8",
|
||||
"dest": "cargo/vendor/winnow-1.0.0"
|
||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate",
|
||||
"sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5",
|
||||
"dest": "cargo/vendor/winnow-1.0.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/winnow-1.0.0",
|
||||
"contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/winnow-1.0.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+91
-97
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1502,9 +1502,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1516,7 +1516,6 @@ dependencies = [
|
||||
"httparse",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -1600,12 +1599,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
@@ -1613,9 +1613,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -1626,9 +1626,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
@@ -1640,15 +1640,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.1.2"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
@@ -1660,15 +1660,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.1.2"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -1725,9 +1725,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
@@ -1854,9 +1854,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1905,7 +1905,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
|
||||
dependencies = [
|
||||
"cssparser 0.29.6",
|
||||
"html5ever 0.29.1",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
@@ -1941,9 +1941,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -1975,9 +1975,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -2104,7 +2104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"serde",
|
||||
@@ -2123,9 +2123,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
|
||||
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2773,12 +2773,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -2798,7 +2792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -2819,9 +2813,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
@@ -2883,7 +2877,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.8+spec-1.1.0",
|
||||
"toml_edit 0.25.10+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3707,9 +3701,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3736,7 +3730,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
@@ -4670,9 +4664,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
@@ -4760,9 +4754,9 @@ version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.0",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -4789,9 +4783,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -4802,7 +4796,7 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
@@ -4813,7 +4807,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
@@ -4822,30 +4816,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.8+spec-1.1.0"
|
||||
version = "0.25.10+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.1.0+spec-1.1.0",
|
||||
"indexmap 2.13.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.0",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.0",
|
||||
"winnow 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -5157,9 +5151,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.115"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5170,9 +5164,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.65"
|
||||
version = "0.4.67"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5180,9 +5174,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.115"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5190,9 +5184,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.115"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5203,9 +5197,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.115"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -5227,7 +5221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -5253,15 +5247,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5968,9 +5962,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -6013,7 +6007,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"prettyplease",
|
||||
"syn 2.0.117",
|
||||
"wasm-metadata",
|
||||
@@ -6044,7 +6038,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -6063,7 +6057,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -6075,9 +6069,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
@@ -6156,9 +6150,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -6167,9 +6161,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6199,18 +6193,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6226,9 +6220,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
@@ -6237,9 +6231,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -6248,9 +6242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.2"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6265,7 +6259,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"indexmap 2.13.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+135
-28
@@ -5,6 +5,8 @@
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { gql } from "./lib/client";
|
||||
import logoUrl from "./assets/moku-icon-splash.svg";
|
||||
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||
@@ -76,6 +78,13 @@
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
|
||||
let loginRequired = $state(false);
|
||||
let loginUser = $state(store.settings.serverAuthUser ?? "");
|
||||
let loginPass = $state("");
|
||||
let loginError = $state<string | null>(null);
|
||||
let loginBusy = $state(false);
|
||||
let unsupportedMode = $state(false);
|
||||
|
||||
let platformScale = $state(1.0);
|
||||
let _appliedZoom = -1;
|
||||
let _vhRafId: number | null = null;
|
||||
@@ -195,30 +204,35 @@
|
||||
function startProbe() {
|
||||
cancelProbe = false;
|
||||
failed = false;
|
||||
loginRequired = false;
|
||||
let tries = 0;
|
||||
|
||||
async function probe() {
|
||||
if (cancelProbe) return;
|
||||
tries++;
|
||||
try {
|
||||
const rawUrl = store.settings.serverUrl;
|
||||
const base = typeof rawUrl === "string" && rawUrl.trim()
|
||||
? rawUrl.replace(/\/$/, "")
|
||||
: "http://127.0.0.1:4567";
|
||||
const s = store.settings;
|
||||
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
|
||||
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
|
||||
: {};
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...auth },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
|
||||
if (!cancelProbe) setTimeout(probe, 750);
|
||||
const result = await probeServer();
|
||||
if (cancelProbe) return;
|
||||
|
||||
if (result === "ok") {
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "auth_required") {
|
||||
serverProbeOk = true;
|
||||
loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "unsupported_mode") {
|
||||
serverProbeOk = true;
|
||||
unsupportedMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||
setTimeout(probe, 750);
|
||||
}
|
||||
|
||||
setTimeout(probe, 800);
|
||||
@@ -310,36 +324,106 @@
|
||||
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginUser.trim() || !loginPass.trim()) {
|
||||
loginError = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
loginBusy = true;
|
||||
loginError = null;
|
||||
try {
|
||||
await loginBasic(loginUser.trim(), loginPass.trim());
|
||||
loginRequired = false;
|
||||
loginPass = "";
|
||||
loginError = null;
|
||||
appReady = true;
|
||||
} catch (e: any) {
|
||||
loginError = e?.message ?? "Login failed";
|
||||
} finally {
|
||||
loginBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
failed = false;
|
||||
notConfigured = false;
|
||||
serverProbeOk = false;
|
||||
failed = false;
|
||||
notConfigured = false;
|
||||
serverProbeOk = false;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
startProbe();
|
||||
}
|
||||
|
||||
function handleBypass() {
|
||||
cancelProbe = true;
|
||||
serverProbeOk = true;
|
||||
appReady = true;
|
||||
cancelProbe = true;
|
||||
serverProbeOk = true;
|
||||
loginRequired = false;
|
||||
unsupportedMode = false;
|
||||
appReady = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
{:else if !appReady}
|
||||
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => appReady = true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={handleRetry}
|
||||
onBypass={handleBypass} />
|
||||
{:else if unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||
}</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
<p class="auth-body">
|
||||
<strong>{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||
</p>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">Basic Auth</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
{#if loginError}
|
||||
<p class="auth-error">{loginError}</p>
|
||||
{/if}
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||
{loginBusy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="app-shell" class="root">
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||
{/if}
|
||||
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
@@ -358,4 +442,27 @@
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
/* Auth overlay — floats above the SplashScreen */
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.auth-input:disabled { opacity: 0.5; }
|
||||
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||
</style>
|
||||
@@ -3,8 +3,10 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const isMac = platform() === "macos";
|
||||
const win = getCurrentWindow();
|
||||
const os = platform();
|
||||
const isMac = os === "macos";
|
||||
const isWindows = os === "windows";
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
|
||||
@@ -18,19 +20,39 @@
|
||||
</script>
|
||||
|
||||
{#if !isFullscreen}
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
{#if !isMac}
|
||||
<div class="controls">
|
||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
{#if !isMac}
|
||||
<div class="controls">
|
||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isWindows}
|
||||
<!-- On Windows, fullscreen hides the native titlebar — show a hoverable overlay so the user isn't locked in -->
|
||||
<div class="fullscreen-controls">
|
||||
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
@@ -40,11 +62,24 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fullscreen-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.fullscreen-controls:hover { opacity: 1; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{#if store.toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each store.toasts as t (t.id)}
|
||||
<div
|
||||
<button
|
||||
class="toast toast-{t.kind}"
|
||||
role="alert"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
@@ -43,7 +43,7 @@
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -76,6 +76,10 @@
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toast:hover { opacity: 0.85; transform: translateX(-2px); }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
||||
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } from "../../lib/types";
|
||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||
@@ -69,7 +69,8 @@
|
||||
|
||||
function rotatedSources(): Source[] {
|
||||
const lang = store.settings.preferredExtensionLang || "en";
|
||||
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
||||
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||
const srcs = dedupeSources(eligible, lang);
|
||||
if (!srcs.length) return [];
|
||||
const off = store.discoverSrcOffset % srcs.length;
|
||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-1); }
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.4; }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
const tabStatus = $derived(
|
||||
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
|
||||
);
|
||||
const tabFilters = $derived(
|
||||
store.settings.libraryTabFilters?.[store.libraryFilter] ?? {} as Partial<Record<LibraryContentFilter, boolean>>
|
||||
);
|
||||
const hasActiveFilters = $derived(
|
||||
tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)
|
||||
);
|
||||
|
||||
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
|
||||
const prev = store.settings.libraryTabSort[store.libraryFilter];
|
||||
@@ -96,7 +102,32 @@
|
||||
[store.libraryFilter]: status,
|
||||
},
|
||||
});
|
||||
filterPanelOpen = false;
|
||||
}
|
||||
|
||||
function toggleTabFilter(filter: LibraryContentFilter) {
|
||||
const current = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||||
updateSettings({
|
||||
libraryTabFilters: {
|
||||
...(store.settings.libraryTabFilters ?? {}),
|
||||
[store.libraryFilter]: {
|
||||
...current,
|
||||
[filter]: !current[filter],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
updateSettings({
|
||||
libraryTabStatus: {
|
||||
...store.settings.libraryTabStatus,
|
||||
[store.libraryFilter]: "ALL",
|
||||
},
|
||||
libraryTabFilters: {
|
||||
...(store.settings.libraryTabFilters ?? {}),
|
||||
[store.libraryFilter]: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let allManga: Manga[] = $state([]);
|
||||
@@ -333,6 +364,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 4b. Content filters (additive — each active filter further narrows the list)
|
||||
const filters = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||||
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (filters.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
|
||||
if (filters.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
if (filters.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
|
||||
|
||||
// 5. Sort
|
||||
const recentlyReadMap = new Map<number, number>();
|
||||
if (mode === "recentlyRead") {
|
||||
@@ -712,7 +750,11 @@
|
||||
</button>
|
||||
{#if sortPanelOpen}
|
||||
<div class="dropdown-panel sort-panel" role="menu">
|
||||
<p class="panel-label">Sort by</p>
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Sort</span>
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Order by</p>
|
||||
{#each ALL_SORT_MODES as m}
|
||||
<button
|
||||
class="panel-item"
|
||||
@@ -750,22 +792,47 @@
|
||||
<div class="filter-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:icon-btn-active={tabStatus !== "ALL"}
|
||||
title="Filter by status"
|
||||
class:icon-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={openFilterPanel}
|
||||
>
|
||||
<Funnel size={15} weight={tabStatus !== "ALL" ? "fill" : "bold"} />
|
||||
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
{#if filterPanelOpen}
|
||||
<div class="dropdown-panel filter-panel" role="menu">
|
||||
<p class="panel-label">Filter by status</p>
|
||||
{#each ALL_STATUS_FILTERS as s}
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={clearAllFilters}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Content</p>
|
||||
{#each ([["unread","Unread"],["started","Started"],["downloaded","Downloaded"],["bookmarked","Bookmarked"]] as const) as [f, label]}
|
||||
<button
|
||||
class="panel-item"
|
||||
class="panel-item panel-item-check"
|
||||
class:panel-item-active={tabFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => toggleTabFilter(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
|
||||
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Status</p>
|
||||
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
|
||||
<button
|
||||
class="panel-item panel-item-check"
|
||||
class:panel-item-active={tabStatus === s}
|
||||
role="menuitem"
|
||||
onclick={() => setTabStatus(s)}
|
||||
onclick={() => setTabStatus(tabStatus === s ? "ALL" : s)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={tabStatus === s}>
|
||||
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{STATUS_LABELS[s]}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -934,8 +1001,17 @@
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
/* Panel header row — shared by sort + filter panels */
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||
.panel-clear-btn:hover { color: var(--color-error); }
|
||||
/* Check items */
|
||||
.panel-item-check { justify-content: flex-start; gap: var(--sp-2); }
|
||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
|
||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
||||
.sort-caret { flex-shrink: 0; }
|
||||
:global(.sort-caret) { flex-shrink: 0; }
|
||||
|
||||
/* ── Selection toolbar ──────────────────────────────────────────────────── */
|
||||
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import { store } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +38,46 @@
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
|
||||
// Lang filter: "en" first, then alphabetical
|
||||
let selectedLang: string = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const strip = langStripEl;
|
||||
const chips = Array.from(strip.children) as HTMLElement[];
|
||||
const scrollLeft = strip.scrollLeft;
|
||||
const viewEnd = scrollLeft + strip.clientWidth;
|
||||
|
||||
if (dir === 1) {
|
||||
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
|
||||
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
if (s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
@@ -52,7 +93,14 @@
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
||||
.then((d) => {
|
||||
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
|
||||
sources = filtered;
|
||||
// Pre-select preferred lang if available and there are multiple
|
||||
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||
const langs = new Set(filtered.map(s => s.lang));
|
||||
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
@@ -178,15 +226,29 @@
|
||||
|
||||
<!-- Step 1: Pick source -->
|
||||
{#if step === "source"}
|
||||
<div class="source-list">
|
||||
{#if loadingSources}
|
||||
<div class="centered">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{#if loadingSources}
|
||||
<div class="centered">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
{:else if sources.length === 0}
|
||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||
{:else}
|
||||
{#if hasMultipleLangs}
|
||||
<div class="src-lang-bar">
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||
</div>
|
||||
{:else if sources.length === 0}
|
||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||
{:else}
|
||||
{#each sources as src}
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button
|
||||
class="source-row"
|
||||
class:source-row-active={selectedSource?.id === src.id}
|
||||
@@ -200,8 +262,8 @@
|
||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Search & pick match -->
|
||||
{:else if step === "search"}
|
||||
@@ -400,6 +462,18 @@
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
/* Lang filter bar */
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
|
||||
.src-lang-chip:last-child { margin-right: var(--sp-1); }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
/* Search step */
|
||||
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||
|
||||
+473
-1008
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import MigrateModal from "./MigrateModal.svelte";
|
||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||
import AutomationPanel from "../shared/AutomationPanel.svelte";
|
||||
import MarkersPanel from "../shared/MarkersPanel.svelte";
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -18,90 +22,82 @@
|
||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingManga: boolean = $state(false);
|
||||
let loadingChapters: boolean = $state(true);
|
||||
let enqueueing: Set<number> = $state(new Set());
|
||||
let dlOpen: boolean = $state(false);
|
||||
let detailsOpen: boolean = $state(false);
|
||||
let togglingLibrary: boolean = $state(false);
|
||||
let chapterPage: number = $state(1);
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingManga: boolean = $state(false);
|
||||
let loadingChapters: boolean = $state(true);
|
||||
let enqueueing: Set<number> = $state(new Set());
|
||||
let dlOpen: boolean = $state(false);
|
||||
let manageOpen: boolean = $state(false);
|
||||
let togglingLibrary: boolean = $state(false);
|
||||
let chapterPage: number = $state(1);
|
||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
||||
let jumpOpen: boolean = $state(false);
|
||||
let jumpInput: string = $state("");
|
||||
let jumpOpen: boolean = $state(false);
|
||||
let jumpInput: string = $state("");
|
||||
let viewMode: "list" | "grid" = $state("list");
|
||||
let deletingAll: boolean = $state(false);
|
||||
let refreshing: boolean = $state(false);
|
||||
let genresExpanded: boolean = $state(false);
|
||||
let deletingAll: boolean = $state(false);
|
||||
let refreshing: boolean = $state(false);
|
||||
let genresExpanded: boolean = $state(false);
|
||||
let folderPickerOpen: boolean = $state(false);
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
let migrateOpen: boolean = $state(false);
|
||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
let migrateOpen: boolean = $state(false);
|
||||
let autoOpen: boolean = $state(false);
|
||||
let trackingOpen: boolean = $state(false);
|
||||
let markersOpen: boolean = $state(false);
|
||||
let linkPickerOpen: boolean = $state(false);
|
||||
let linkSearch: string = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList: boolean = $state(false);
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let sortMenuOpen: boolean = $state(false);
|
||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||
let mangaAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
let loadingFor: number | null = null;
|
||||
let _prevChapterIds: Set<number> = new Set();
|
||||
|
||||
// Series link state
|
||||
let linkPickerOpen: boolean = $state(false);
|
||||
let linkSearch: string = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList: boolean = $state(false);
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
|
||||
// Tracking modal
|
||||
let trackingOpen: boolean = $state(false);
|
||||
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
|
||||
if (!store.activeManga) return {};
|
||||
return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
|
||||
});
|
||||
|
||||
let mangaAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
let loadingFor: number | null = null;
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
chapters = nodes;
|
||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||
}
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
const sortDir = $derived(store.settings.chapterSortDir);
|
||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
||||
let sortMenuOpen = $state(false);
|
||||
const sortDir = $derived(store.settings.chapterSortDir);
|
||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
||||
|
||||
const sortedChapters = $derived.by(() => {
|
||||
const base = [...chapters];
|
||||
if (sortMode === "chapterNumber") {
|
||||
base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
} else if (sortMode === "uploadDate") {
|
||||
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||
} else {
|
||||
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
}
|
||||
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
|
||||
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
return sortDir === "desc" ? base.reverse() : base;
|
||||
});
|
||||
|
||||
/**
|
||||
* Chapter list in canonical reading order (ch1 -> ch2 -> ch3).
|
||||
* Always passed to openReader so the Reader's idx-based prev/next
|
||||
* navigation is direction-independent of the user's display sort.
|
||||
*/
|
||||
const chaptersAsc = $derived(
|
||||
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||
);
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||
const totalCount = $derived(chapters.length);
|
||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||
const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||
const totalCount = $derived(chapters.length);
|
||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
|
||||
const continueChapter = $derived((() => {
|
||||
if (!chapters.length) return null;
|
||||
@@ -114,9 +110,71 @@
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
})());
|
||||
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
const jumpChapter = $derived.by(() => {
|
||||
const q = jumpInput.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const num = parseFloat(q);
|
||||
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
|
||||
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
|
||||
});
|
||||
|
||||
const hasAnyAutomation = $derived(
|
||||
getPref("autoDownload") ||
|
||||
getPref("downloadAhead") > 0 ||
|
||||
getPref("maxKeepChapters") > 0 ||
|
||||
getPref("deleteOnRead") ||
|
||||
getPref("pauseUpdates") ||
|
||||
getPref("refreshInterval") !== "global" ||
|
||||
!!getPref("preferredScanlator")
|
||||
);
|
||||
|
||||
const linkedIds = $derived(
|
||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||
);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
|
||||
function doJump() {
|
||||
if (!jumpChapter) return;
|
||||
const pageIdx = sortedChapters.indexOf(jumpChapter);
|
||||
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
|
||||
jumpOpen = false;
|
||||
jumpInput = "";
|
||||
}
|
||||
|
||||
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
selectedIds = next;
|
||||
}
|
||||
|
||||
function clearSelection() { selectedIds = new Set(); }
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
|
||||
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
||||
}
|
||||
_prevChapterIds = new Set(nodes.map(c => c.id));
|
||||
chapters = nodes;
|
||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||
}
|
||||
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
@@ -131,7 +189,6 @@
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
// Sync local mangaCategories state after the mutation
|
||||
if (chaps.length) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
@@ -215,6 +272,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||
});
|
||||
$effect(() => {
|
||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||
});
|
||||
|
||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
togglingLibrary = true;
|
||||
@@ -252,6 +321,14 @@
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||
if (isRead && getPref("deleteOnRead")) {
|
||||
const ch = chapters.find(c => c.id === chapterId);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||
if (delayMs === 0) deleteDownloaded(chapterId);
|
||||
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function markBulk(ids: number[], isRead: boolean) {
|
||||
@@ -260,6 +337,40 @@
|
||||
const idSet = new Set(ids);
|
||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||
if (isRead && getPref("deleteOnRead")) {
|
||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||
if (toDelete.length) {
|
||||
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
|
||||
const doDelete = async () => {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||
};
|
||||
if (delayMs === 0) doDelete();
|
||||
else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||
if (ids.length) {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||
}
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
|
||||
await enqueueMultiple(ids);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function markSelectedRead(isRead: boolean) {
|
||||
await markBulk([...selectedIds], isRead);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
||||
@@ -312,18 +423,6 @@
|
||||
];
|
||||
}
|
||||
|
||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
||||
|
||||
$effect(() => {
|
||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
||||
});
|
||||
$effect(() => {
|
||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
||||
});
|
||||
|
||||
function enqueueNext(n: number) {
|
||||
if (!continueChapter) return;
|
||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
||||
@@ -360,29 +459,21 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
|
||||
// ── Series link ────────────────────────────────────────────────────────────
|
||||
|
||||
const linkedIds = $derived(
|
||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||
);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
|
||||
const ahead = getPref("downloadAhead");
|
||||
if (ahead > 0) {
|
||||
const idx = list.indexOf(ch);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
|
||||
if (toQueue.length) enqueueMultiple(toQueue);
|
||||
}
|
||||
}
|
||||
openReader(ch, list);
|
||||
}
|
||||
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true; linkSearch = "";
|
||||
@@ -401,23 +492,22 @@
|
||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
||||
else linkManga(store.activeManga.id, other.id);
|
||||
}
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
</script>
|
||||
|
||||
{#if store.activeManga}
|
||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
||||
|
||||
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
|
||||
<div class="sidebar">
|
||||
<button class="back" onclick={() => setActiveManga(null)}>
|
||||
<ArrowLeft size={13} weight="light" /> Back
|
||||
</button>
|
||||
|
||||
<!-- Zone 1: Cover -->
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
||||
</div>
|
||||
|
||||
<!-- Zone 2: Meta -->
|
||||
{#if loadingManga}
|
||||
<div class="meta-skeleton">
|
||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
||||
@@ -450,10 +540,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Zone 3: Primary CTA + library action -->
|
||||
<div class="cta-section">
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, chaptersAsc)}>
|
||||
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
@@ -473,7 +562,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone 4: Progress -->
|
||||
{#if totalCount > 0}
|
||||
<div class="progress-section">
|
||||
<div class="progress-header">
|
||||
@@ -484,38 +572,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
|
||||
{#if !loadingManga && manga?.source}
|
||||
{#if !loadingManga && manga}
|
||||
<div class="details-section">
|
||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
||||
<span>Details</span>
|
||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
|
||||
<span>Manage</span>
|
||||
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
||||
</button>
|
||||
{#if detailsOpen}
|
||||
{#if manageOpen}
|
||||
<div class="details-body">
|
||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
|
||||
<Eye size={12} weight="light" /> Preview
|
||||
</button>
|
||||
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
||||
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
||||
</button>
|
||||
<button
|
||||
class="detail-action-btn"
|
||||
class:detail-action-active={linkedIds.length > 0}
|
||||
onclick={openLinkPicker}
|
||||
>
|
||||
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
|
||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
class="detail-action-btn"
|
||||
onclick={() => trackingOpen = true}
|
||||
>
|
||||
<button class="detail-action-btn" onclick={() => trackingOpen = true}>
|
||||
<ChartLineUp size={12} weight="light" /> Tracking
|
||||
</button>
|
||||
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
|
||||
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
|
||||
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
|
||||
</button>
|
||||
{#if manga?.inLibrary}
|
||||
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
|
||||
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
|
||||
</button>
|
||||
{/if}
|
||||
{#if downloadedCount > 0}
|
||||
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
||||
@@ -528,42 +615,65 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
<div class="list-wrap">
|
||||
<div class="list-header">
|
||||
<div class="list-header-left">
|
||||
<div class="sort-wrap">
|
||||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
||||
<CaretDown size={10} weight="light" />
|
||||
</button>
|
||||
{#if sortMenuOpen}
|
||||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||||
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
||||
<button class="sort-option" class:active={sortMode === val}
|
||||
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{label}
|
||||
{#if hasSelection}
|
||||
<span class="sel-count">{selectedIds.size} selected</span>
|
||||
<button class="sel-action-btn" onclick={downloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
|
||||
<button class="sel-action-btn" onclick={clearSelection} title="Clear selection"><X size={13} weight="light" /></button>
|
||||
{:else}
|
||||
<div class="sort-wrap">
|
||||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
|
||||
<CaretDown size={10} weight="light" />
|
||||
</button>
|
||||
{#if sortMenuOpen}
|
||||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||||
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
|
||||
<button class="sort-option" class:active={sortMode === val}
|
||||
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="sort-divider"></div>
|
||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="sort-divider"></div>
|
||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
|
||||
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list-header-right">
|
||||
<div class="jump-wrap">
|
||||
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
||||
<MagnifyingGlass size={14} weight="light" />
|
||||
</button>
|
||||
{#if jumpOpen}
|
||||
<div class="jump-popover">
|
||||
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
|
||||
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
|
||||
{#if jumpChapter}
|
||||
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
|
||||
{:else if jumpInput.trim()}
|
||||
<p class="jump-none">No match</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- View toggle: icon reflects current state -->
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-header-right">
|
||||
|
||||
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<!-- Category picker -->
|
||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||
@@ -584,12 +694,10 @@
|
||||
<div class="fp-div"></div>
|
||||
{#if folderCreating}
|
||||
<div class="fp-create">
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
|
||||
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
|
||||
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
||||
@@ -598,14 +706,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Download dropdown -->
|
||||
{#if chapters.length > 0}
|
||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
||||
<Download size={13} weight="light" />
|
||||
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||||
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
|
||||
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
|
||||
</button>
|
||||
{#if dlOpen}
|
||||
<div class="dl-dropdown">
|
||||
{#if downloadedCount > 0}
|
||||
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
|
||||
<div class="dl-divider"></div>
|
||||
{/if}
|
||||
{#if continueChapter}
|
||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
||||
{#if contIdx >= 0}
|
||||
@@ -673,11 +785,13 @@
|
||||
{:else if viewMode === "grid"}
|
||||
{#each sortedChapters as ch, i}
|
||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
|
||||
onclick={() => openReader(ch, chaptersAsc)}
|
||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||
title={ch.name}>
|
||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
||||
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
|
||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
||||
</button>
|
||||
@@ -685,10 +799,14 @@
|
||||
{:else}
|
||||
{#each pageChapters as ch}
|
||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
||||
onclick={() => openReader(ch, chaptersAsc)}
|
||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, chaptersAsc)}
|
||||
{@const isSelected = selectedIds.has(ch.id)}
|
||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
|
||||
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
|
||||
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc))}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
||||
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
|
||||
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
||||
</button>
|
||||
<div class="ch-left">
|
||||
<span class="ch-name">{ch.name}</span>
|
||||
<div class="ch-meta">
|
||||
@@ -700,11 +818,12 @@
|
||||
<div class="ch-right">
|
||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||
{#if ch.isDownloaded}
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
||||
<span class="ch-dl-dot" title="Downloaded"></span>
|
||||
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
|
||||
{:else if enqueueing.has(ch.id)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||
{:else}
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -736,20 +855,25 @@
|
||||
{/if}
|
||||
|
||||
{#if trackingOpen && store.activeManga}
|
||||
<TrackingPanel
|
||||
mangaId={store.activeManga.id}
|
||||
mangaTitle={store.activeManga.title}
|
||||
onClose={() => trackingOpen = false}
|
||||
/>
|
||||
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
|
||||
{/if}
|
||||
|
||||
{#if autoOpen && store.activeManga}
|
||||
<AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
|
||||
{/if}
|
||||
|
||||
{#if markersOpen && store.activeManga}
|
||||
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
|
||||
<div class="markers-panel-drawer">
|
||||
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPickerOpen}
|
||||
<div
|
||||
class="link-backdrop"
|
||||
role="presentation"
|
||||
<div class="link-backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
||||
>
|
||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
||||
<div class="link-modal">
|
||||
<div class="link-header">
|
||||
<span class="link-title">Link as same series</span>
|
||||
@@ -787,16 +911,13 @@
|
||||
<style>
|
||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
/* Zone 1: Cover */
|
||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Zone 2: Meta */
|
||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-line { border-radius: var(--radius-sm); }
|
||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
@@ -810,10 +931,8 @@
|
||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
/* Description clamped — no expand in 240px sidebar */
|
||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
/* Zone 3: CTA */
|
||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { opacity: 0.88; }
|
||||
@@ -825,7 +944,6 @@
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
/* Zone 4: Progress */
|
||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
@@ -833,14 +951,10 @@
|
||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
|
||||
/* Zone 5: Details accordion */
|
||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||
.details-toggle:hover { color: var(--text-muted); }
|
||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
||||
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
||||
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
@@ -850,7 +964,6 @@
|
||||
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
||||
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Series link modal ───────────────────────────────────────────────────── */
|
||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
@@ -874,7 +987,6 @@
|
||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
/* ── Chapter list ────────────────────────────────────────────────────────── */
|
||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
@@ -891,7 +1003,14 @@
|
||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||
.jump-wrap { position: relative; }
|
||||
.jump-popover { position: absolute; top: calc(100% + 4px); right: 0; width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.jump-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 5px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||
.jump-input:focus { border-color: var(--border-focus); }
|
||||
.jump-go { width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||||
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.fp-wrap { position: relative; }
|
||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
@@ -910,7 +1029,6 @@
|
||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Download dropdown ───────────────────────────────────────────────────── */
|
||||
.dl-wrap { position: relative; }
|
||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
@@ -935,7 +1053,6 @@
|
||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Pagination ──────────────────────────────────────────────────────────── */
|
||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
@@ -943,7 +1060,6 @@
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* ── Chapter rows ────────────────────────────────────────────────────────── */
|
||||
.ch-list { flex: 1; overflow-y: auto; }
|
||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
@@ -969,10 +1085,41 @@
|
||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||
|
||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); }
|
||||
.sel-action-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.sel-action-danger { color: var(--color-error) !important; }
|
||||
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||||
|
||||
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
|
||||
.dl-unified-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); transition: color var(--t-base); }
|
||||
.dl-unified-btn:hover .dl-unified-count,
|
||||
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
|
||||
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
|
||||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
|
||||
.ch-row:hover .ch-check { opacity: 1; }
|
||||
.ch-check-visible { opacity: 1 !important; }
|
||||
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
|
||||
|
||||
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
|
||||
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||
|
||||
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); }
|
||||
.ch-row:hover .ch-dl-dot { opacity: 0; }
|
||||
|
||||
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
|
||||
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
|
||||
</style>
|
||||
|
||||
@@ -493,41 +493,51 @@
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
||||
.tracker-tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 9px 10px 8px;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0; cursor: pointer; white-space: nowrap;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); }
|
||||
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
|
||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
|
||||
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
||||
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; }
|
||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.filter-search::placeholder { color: var(--text-faint); }
|
||||
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.filter-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 28px 5px 10px; border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-muted); outline: none; cursor: pointer;
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
|
||||
/* ── States ─────────────────────────────────────────────────────────────── */
|
||||
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
||||
@@ -535,29 +545,28 @@
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Records list ───────────────────────────────────────────────────────── */
|
||||
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.records-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.record-card {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
transition: border-color var(--t-base), opacity var(--t-base);
|
||||
background: none;
|
||||
transition: background var(--t-fast), opacity var(--t-base);
|
||||
}
|
||||
.record-card:hover { border-color: var(--border-strong); }
|
||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
||||
.record-card:hover { background: var(--bg-raised); }
|
||||
.record-busy { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Cover */
|
||||
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
|
||||
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
|
||||
.record-cover-empty { background: var(--bg-overlay); }
|
||||
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
|
||||
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
|
||||
.record-cover-wrap:hover .record-cover { opacity: 0.75; }
|
||||
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
|
||||
|
||||
/* Body */
|
||||
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@@ -566,9 +575,9 @@
|
||||
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
||||
.record-titles:hover .record-title { color: var(--accent-fg); }
|
||||
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
|
||||
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
||||
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
|
||||
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
@@ -577,24 +586,24 @@
|
||||
/* Controls */
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
transition: border-color var(--t-base);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
||||
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 90px; }
|
||||
.record-select-score { max-width: 86px; }
|
||||
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
||||
|
||||
/* Progress */
|
||||
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
|
||||
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
||||
@@ -604,21 +613,21 @@
|
||||
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||
Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||
Bookmark, BookOpen, MonitorPlay,
|
||||
Bookmark, BookOpen, MonitorPlay, MapPin, Check,
|
||||
} from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark } from "../../store/state.svelte";
|
||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||
import { setReading } from "../../lib/discord";
|
||||
import type { FitMode } from "../../store/state.svelte";
|
||||
import type { FitMode, MarkerColor } from "../../store/state.svelte";
|
||||
|
||||
const AVG_MIN_PER_PAGE = 0.33;
|
||||
const READ_LINE_PCT = 0.20;
|
||||
@@ -22,6 +22,15 @@
|
||||
const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||||
type PageStyle = typeof PAGE_STYLES[number];
|
||||
|
||||
const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
|
||||
const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
|
||||
yellow: "#c4a94a",
|
||||
red: "#c47a7a",
|
||||
blue: "#7a9ec4",
|
||||
green: "#7aab7a",
|
||||
purple: "#a07ac4",
|
||||
};
|
||||
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
|
||||
@@ -119,6 +128,11 @@
|
||||
let resumeFading = $state(false);
|
||||
let resumeVisible = $state(false);
|
||||
|
||||
let markerOpen = $state(false);
|
||||
let markerNote = $state("");
|
||||
let markerColor: MarkerColor = $state("yellow");
|
||||
let markerEditId = $state("");
|
||||
|
||||
function scheduleResumeDismiss() {
|
||||
if (resumeTimer) clearTimeout(resumeTimer);
|
||||
if (resumeFadeTimer) clearTimeout(resumeFadeTimer);
|
||||
@@ -150,6 +164,16 @@
|
||||
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined);
|
||||
const isBookmarked = $derived(!!currentBookmark && currentBookmark.pageNumber === store.pageNumber);
|
||||
|
||||
const currentPageMarkers = $derived(
|
||||
displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []
|
||||
);
|
||||
|
||||
const activeChapterMarkers = $derived(
|
||||
displayChapter ? store.getMarkersForChapter(displayChapter.id) : []
|
||||
);
|
||||
|
||||
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
|
||||
|
||||
const showResumeBanner = $derived(
|
||||
resumeVisible && resumePage > 1 &&
|
||||
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
|
||||
@@ -239,6 +263,7 @@
|
||||
visibleChapterId = null;
|
||||
store.pageUrls = [];
|
||||
fadingOut = false;
|
||||
markerOpen = false;
|
||||
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||
@@ -297,6 +322,8 @@
|
||||
const chId = visibleChapterId;
|
||||
if (!chId || style !== "longstrip") return;
|
||||
if (chId === store.activeChapter?.id) return;
|
||||
const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
||||
if (wasAppended) { untrack(() => { resumePage = 0; resumeVisible = false; }); return; }
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
|
||||
if (bookmark && bookmark.pageNumber > 1) {
|
||||
untrack(() => {
|
||||
@@ -571,11 +598,61 @@
|
||||
if (!ch || !manga) return;
|
||||
if (isBookmarked) {
|
||||
removeBookmark(ch.id);
|
||||
resumeVisible = false;
|
||||
} else {
|
||||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber });
|
||||
}
|
||||
}
|
||||
|
||||
function openMarkerPopover() {
|
||||
const ch = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (!ch || !manga) return;
|
||||
if (currentPageMarkers.length > 0) {
|
||||
const first = currentPageMarkers[0];
|
||||
markerEditId = first.id;
|
||||
markerNote = first.note;
|
||||
markerColor = first.color;
|
||||
} else {
|
||||
markerEditId = "";
|
||||
markerNote = "";
|
||||
markerColor = "yellow";
|
||||
}
|
||||
markerOpen = !markerOpen;
|
||||
zoomOpen = false;
|
||||
dlOpen = false;
|
||||
}
|
||||
|
||||
function commitMarker() {
|
||||
const ch = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (!ch || !manga) return;
|
||||
if (markerEditId) {
|
||||
updateMarker(markerEditId, { note: markerNote.trim(), color: markerColor });
|
||||
} else {
|
||||
addMarker({
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
chapterId: ch.id,
|
||||
chapterName: ch.name,
|
||||
pageNumber: store.pageNumber,
|
||||
note: markerNote.trim(),
|
||||
color: markerColor,
|
||||
});
|
||||
}
|
||||
markerOpen = false;
|
||||
markerNote = "";
|
||||
markerEditId = "";
|
||||
}
|
||||
|
||||
function deleteCurrentMarker() {
|
||||
if (markerEditId) removeMarker(markerEditId);
|
||||
markerOpen = false;
|
||||
markerNote = "";
|
||||
markerEditId = "";
|
||||
}
|
||||
|
||||
function cycleStyle() {
|
||||
const idx = PAGE_STYLES.indexOf(style);
|
||||
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
|
||||
@@ -589,9 +666,16 @@
|
||||
function showUi() {
|
||||
uiVisible = true;
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
||||
hideTimer = setTimeout(() => { if (!markerOpen) uiVisible = false; }, 3000);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (markerOpen) {
|
||||
uiVisible = true;
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||
}
|
||||
});
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
@@ -603,13 +687,14 @@
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
|
||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||
const r = store.settings.readingDirection === "rtl";
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (zoomOpen) { zoomOpen = false; return; }
|
||||
if (dlOpen) { dlOpen = false; return; }
|
||||
if (markerOpen) { markerOpen = false; return; }
|
||||
if (zoomOpen) { zoomOpen = false; return; }
|
||||
if (dlOpen) { dlOpen = false; return; }
|
||||
closeReader(); return;
|
||||
}
|
||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
|
||||
@@ -639,6 +724,7 @@
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
|
||||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); openMarkerPopover(); }
|
||||
}
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
@@ -765,29 +851,79 @@
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||
<div class="marker-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={hasMarkerOnPage}
|
||||
class:marker-btn-has={hasMarkerOnPage}
|
||||
onclick={openMarkerPopover}
|
||||
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
|
||||
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
|
||||
>
|
||||
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
{#if markerOpen}
|
||||
<div class="marker-popover" role="presentation" onclick={(e) => e.stopPropagation()}
|
||||
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
|
||||
>
|
||||
<div class="marker-pop-header">
|
||||
<span class="marker-pop-title">
|
||||
{markerEditId ? "Edit marker" : "New marker"} · p.{store.pageNumber}
|
||||
</span>
|
||||
{#if markerEditId}
|
||||
<button class="marker-delete-btn" onclick={deleteCurrentMarker} title="Delete marker">
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="marker-color-row">
|
||||
{#each MARKER_COLORS as c}
|
||||
<button
|
||||
class="marker-swatch"
|
||||
class:marker-swatch-active={markerColor === c}
|
||||
style="--swatch:{MARKER_COLOR_HEX[c]}"
|
||||
onclick={() => markerColor = c}
|
||||
title={c}
|
||||
>
|
||||
<span class="swatch-dot"></span>
|
||||
<span class="swatch-label">{c}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
class="marker-textarea"
|
||||
style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}"
|
||||
rows={3}
|
||||
placeholder="Note (optional)…"
|
||||
bind:value={markerNote}
|
||||
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitMarker(); }
|
||||
if (e.key === "Escape") { markerOpen = false; }
|
||||
}}
|
||||
></textarea>
|
||||
<div class="marker-pop-actions">
|
||||
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}" onclick={commitMarker}>
|
||||
<Check size={12} weight="bold" />
|
||||
{markerEditId ? "Update" : "Save"}
|
||||
</button>
|
||||
<button class="marker-cancel-btn" onclick={() => markerOpen = false}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showResumeBanner}
|
||||
<div class="resume-banner" class:fading={resumeFading} role="status" onclick={() => { resumeVisible = false; resumeFading = false; }}>
|
||||
<button class="resume-banner" class:fading={resumeFading} onclick={() => { resumeVisible = false; resumeFading = false; }}>
|
||||
<span>Bookmark at page {resumePage}</span>
|
||||
{#if style === "longstrip" && visibleChapterId && visibleChapterId !== store.activeChapter?.id}
|
||||
<button class="resume-jump" onclick={() => {
|
||||
const chId = visibleChapterId!;
|
||||
const targetPg = resumePage;
|
||||
const scrollToPage = () => {
|
||||
const target = containerEl.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
||||
if (!target) { requestAnimationFrame(scrollToPage); return; }
|
||||
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
};
|
||||
scrollToPage();
|
||||
resumeVisible = false;
|
||||
}}>Jump</button>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
@@ -872,6 +1008,7 @@
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style="width: {rtl ? 100 - sliderPct : sliderPct}%"></div>
|
||||
</div>
|
||||
|
||||
{#if isBookmarked && currentBookmark}
|
||||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div
|
||||
@@ -880,6 +1017,16 @@
|
||||
title="Bookmark: Page {currentBookmark.pageNumber}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div
|
||||
class="slider-checkpoint marker-checkpoint"
|
||||
style="left: {rtl ? 100 - mPct : mPct}%; background:{MARKER_COLOR_HEX[m.color]}"
|
||||
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="slider-input"
|
||||
@@ -958,6 +1105,7 @@
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||
.icon-btn.active { color: var(--accent-fg); }
|
||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||
|
||||
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -985,6 +1133,31 @@
|
||||
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.marker-wrap { position: relative; flex-shrink: 0; }
|
||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--color-error); opacity: 0.55; transition: opacity var(--t-fast), background var(--t-fast); }
|
||||
.marker-delete-btn:hover { opacity: 1; background: var(--color-error-bg); }
|
||||
|
||||
.marker-color-row { display: flex; gap: 5px; }
|
||||
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; padding: 6px 4px 5px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.marker-swatch:hover { background: var(--bg-raised); }
|
||||
.marker-swatch-active { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||||
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||||
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
|
||||
.marker-swatch-active .swatch-label { color: var(--text-muted); }
|
||||
|
||||
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||||
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
|
||||
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||
.marker-save-btn:hover { filter: brightness(1.2); }
|
||||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||
.viewer:focus { outline: none; }
|
||||
@@ -1016,15 +1189,14 @@
|
||||
.slider-input { position: absolute; left: 0; right: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; z-index: 2; }
|
||||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
|
||||
.bookmark-checkpoint { background: var(--accent-fg); opacity: 0.7; }
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||
|
||||
.resume-banner { position: fixed; top: 48px; left: 50%; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; }
|
||||
.resume-banner { position: fixed; top: 48px; left: 50%; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; text-align: left; }
|
||||
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
|
||||
@keyframes bannerIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
|
||||
@keyframes bannerOut { from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } to { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.97); } }
|
||||
.resume-jump { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer; transition: filter var(--t-fast); }
|
||||
.resume-jump:hover { filter: brightness(1.15); }
|
||||
|
||||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
||||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { GET_DOWNLOADS_PATH, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
|
||||
import type { Category, Source } from "../../lib/types";
|
||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
|
||||
import { authSession } from "../../lib/auth";
|
||||
import { cache } from "../../lib/cache";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||
@@ -433,8 +434,17 @@
|
||||
let secLoading = $state(false);
|
||||
let secError = $state<string | null>(null);
|
||||
let secSaved = $state<string | null>(null);
|
||||
let authMode = $state(store.settings.serverAuthMode ?? "NONE");
|
||||
// Warning is based on what the server has confirmed (store value), not the
|
||||
// local draft — so it doesn't fire just because the store has a stale value
|
||||
// before loadServerSecurity runs, and it clears once the user saves a
|
||||
// supported mode.
|
||||
const authModeUnsupported = $derived(
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ||
|
||||
store.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
let authUsername = $state(store.settings.serverAuthUser ?? "");
|
||||
let authPassword = $state(store.settings.serverAuthPass ?? "");
|
||||
let authPassword = $state("");
|
||||
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
|
||||
let socksHost = $state(store.settings.socksProxyHost ?? "");
|
||||
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
|
||||
@@ -463,9 +473,10 @@
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
}}>(GET_SERVER_SECURITY);
|
||||
const s = res.settings;
|
||||
const authOn = s.authMode === "BASIC_AUTH";
|
||||
updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername });
|
||||
authUsername = s.authUsername;
|
||||
const mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
authMode = mode;
|
||||
authUsername = s.authUsername;
|
||||
updateSettings({ serverAuthMode: mode, serverAuthUser: s.authUsername });
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||
socksUsername = s.socksProxyUsername;
|
||||
@@ -483,28 +494,57 @@
|
||||
} catch {}
|
||||
}
|
||||
$effect(() => { if (tab === "security" && !secLoaded) loadServerSecurity(); });
|
||||
async function enableAuth() {
|
||||
if (!authUsername.trim() || !authPassword.trim()) {
|
||||
secError = "Username and password are required"; return;
|
||||
async function saveAuth() {
|
||||
if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) {
|
||||
secError = "Username and password are required for Basic Auth"; return;
|
||||
}
|
||||
secLoading = true; secError = null;
|
||||
updateSettings({ serverAuthEnabled: true, serverAuthUser: authUsername, serverAuthPass: authPassword });
|
||||
|
||||
const prevMode = store.settings.serverAuthMode;
|
||||
const prevUser = store.settings.serverAuthUser;
|
||||
const prevPass = store.settings.serverAuthPass;
|
||||
const newUser = authMode === "BASIC_AUTH" ? authUsername.trim() : "";
|
||||
const newPass = authMode === "BASIC_AUTH" ? authPassword.trim() : "";
|
||||
|
||||
// The store must contain valid credentials while the mutation request is
|
||||
// in-flight so fetchAuthenticated can authenticate it:
|
||||
// - Updating credentials: server still accepts the OLD password, so keep
|
||||
// the old credentials in the store until the server confirms the change.
|
||||
// - First-time enable (store has no pass yet): pre-commit the new
|
||||
// credentials because there is nothing else to send.
|
||||
const isFirstTimeEnable = authMode === "BASIC_AUTH" && !prevPass.trim();
|
||||
if (isFirstTimeEnable) {
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
}
|
||||
|
||||
try {
|
||||
await gql(SET_SERVER_AUTH, { authMode: "BASIC_AUTH", authUsername: authUsername.trim(), authPassword: authPassword.trim() });
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
// On success, commit new credentials (no-op if already pre-committed).
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
if (authMode === "NONE") { authSession.clearTokens(); authPassword = ""; }
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthEnabled: false });
|
||||
secError = e?.message ?? "Failed to enable authentication";
|
||||
// Roll back to previous values on failure.
|
||||
updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass });
|
||||
secError = e?.message ?? "Failed to save authentication settings";
|
||||
} finally { secLoading = false; }
|
||||
}
|
||||
async function disableAuth() {
|
||||
|
||||
async function clearAuth() {
|
||||
secLoading = true; secError = null;
|
||||
const prevMode = store.settings.serverAuthMode;
|
||||
const prevUser = store.settings.serverAuthUser;
|
||||
const prevPass = store.settings.serverAuthPass;
|
||||
// Keep existing credentials in the store so the disable-auth mutation
|
||||
// goes out authenticated, then clear them on success.
|
||||
try {
|
||||
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
|
||||
updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" });
|
||||
authUsername = ""; authPassword = "";
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
authMode = "NONE"; authUsername = ""; authPassword = "";
|
||||
authSession.clearTokens();
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass });
|
||||
secError = e?.message ?? "Failed to disable authentication";
|
||||
} finally { secLoading = false; }
|
||||
}
|
||||
@@ -761,6 +801,7 @@
|
||||
let contentSources: Source[] = $state([]);
|
||||
let contentSourcesLoading: boolean = $state(false);
|
||||
let newTagInput: string = $state("");
|
||||
let tagsRevealed: boolean = $state(false);
|
||||
let sourceSearch: string = $state("");
|
||||
$effect(() => {
|
||||
if (tab === "content" && contentSources.length === 0 && !contentSourcesLoading) {
|
||||
@@ -834,7 +875,7 @@
|
||||
return Array.from(map.values());
|
||||
});
|
||||
</script>
|
||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Settings">
|
||||
<div class="sidebar">
|
||||
<p class="modal-title">Settings</p>
|
||||
@@ -1662,41 +1703,76 @@
|
||||
<div class="section">
|
||||
<div class="section-title-row">
|
||||
<p class="section-title">Server Authentication</p>
|
||||
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthEnabled}>
|
||||
{store.settings.serverAuthEnabled ? "Enabled" : "Disabled"}
|
||||
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthMode === "BASIC_AUTH"} class:sec-pill-warn={authModeUnsupported}>
|
||||
{store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" :
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login — unsupported" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login — unsupported" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if authModeUnsupported}
|
||||
<div class="sec-banner sec-banner-warn" style="margin: var(--sp-2) var(--sp-3) 0;">
|
||||
<strong>{store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"}</strong> is not supported by this client — only <strong>Basic Auth</strong> works here.
|
||||
Switch your Suwayomi server to <code>basic_auth</code> and set the mode below to <strong>Basic</strong>, then save.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Username</span>
|
||||
<span class="toggle-label">Mode</span>
|
||||
<span class="toggle-desc">How Suwayomi verifies requests</span>
|
||||
</div>
|
||||
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Password</span>
|
||||
</div>
|
||||
<div class="sec-field-wrap">
|
||||
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide password" : "Show password"} tabindex="-1">
|
||||
{#if showAuthPass}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="auth-mode-group">
|
||||
{#each [
|
||||
{ value: "NONE", label: "None" },
|
||||
{ value: "BASIC_AUTH", label: "Basic" },
|
||||
] as opt}
|
||||
<button
|
||||
class="auth-mode-btn"
|
||||
class:auth-mode-active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any}
|
||||
disabled={secLoading}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authMode !== "NONE"}
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Username</span></div>
|
||||
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Password</span></div>
|
||||
<div class="sec-field-wrap">
|
||||
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide" : "Show"} tabindex="-1">
|
||||
{#if showAuthPass}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/if}
|
||||
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"></div>
|
||||
<div class="sec-btn-row">
|
||||
{#if store.settings.serverAuthEnabled}
|
||||
<button class="sec-action-btn sec-action-danger" onclick={disableAuth} disabled={secLoading}>
|
||||
{#if store.settings.serverAuthMode !== "NONE"}
|
||||
<button class="sec-action-btn sec-action-danger" onclick={clearAuth} disabled={secLoading}>
|
||||
{secLoading ? "Saving…" : "Disable"}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="sec-action-btn sec-action-primary" onclick={enableAuth} disabled={secLoading || !authUsername.trim() || !authPassword.trim()}>
|
||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthEnabled ? "Update" : "Enable"}
|
||||
<button
|
||||
class="sec-action-btn sec-action-primary"
|
||||
onclick={saveAuth}
|
||||
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}
|
||||
>
|
||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1716,21 +1792,29 @@
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">PIN</span>
|
||||
<span class="toggle-desc">4–8 digits</span>
|
||||
<span class="toggle-desc">4–8 digits, saved on Enter or Save button</span>
|
||||
</div>
|
||||
<div class="sec-pin-wrap">
|
||||
<div class="sec-pin-row">
|
||||
<input class="text-input sec-pin-input" type="password" inputmode="numeric" maxlength={8} value={pinInput}
|
||||
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
||||
onkeydown={(e) => e.key === "Enter" && commitPin()} placeholder="••••" autocomplete="off" />
|
||||
<button class="sec-action-btn sec-action-primary"
|
||||
onclick={commitPin}
|
||||
disabled={pinInput.length > 0 && pinInput.length < 4}>
|
||||
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
|
||||
<div class="sec-btn-row">
|
||||
<input
|
||||
class="text-input"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={8}
|
||||
placeholder="4–8 digits"
|
||||
value={pinInput}
|
||||
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
||||
onkeydown={(e) => e.key === "Enter" && commitPin()}
|
||||
autocomplete="off"
|
||||
aria-label="Enter PIN"
|
||||
style="width:120px;letter-spacing:0.2em"
|
||||
/>
|
||||
<button class="sec-action-btn sec-action-primary"
|
||||
onclick={commitPin}
|
||||
disabled={pinInput.length > 0 && pinInput.length < 4}>
|
||||
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1882,9 +1966,13 @@
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Blocked Genre Tags</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);display:block">
|
||||
Manga whose genres contain any of these substrings are filtered out. Case-insensitive, partial match.
|
||||
</p>
|
||||
<div class="section-title-row" style="padding-top:0;padding-bottom:var(--sp-2)">
|
||||
<span class="toggle-desc" style="flex:1">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
|
||||
<button class="kb-reset" style="font-size:var(--text-xs);padding:2px 10px;flex-shrink:0" onclick={() => tagsRevealed = !tagsRevealed}>
|
||||
{tagsRevealed ? "Hide" : `Show (${(store.settings.nsfwFilteredTags ?? []).length})`}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagsRevealed}
|
||||
<div class="content-tag-grid">
|
||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
||||
<span class="content-tag">
|
||||
@@ -1894,6 +1982,7 @@
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="content-tag-add">
|
||||
<input
|
||||
class="text-input"
|
||||
@@ -2210,14 +2299,6 @@
|
||||
.storage-bar-fill.critical { background: var(--color-error); }
|
||||
.storage-bar-labels { display: flex; justify-content: space-between; }
|
||||
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
|
||||
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.storage-dot-manga { background: var(--accent); }
|
||||
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
||||
.storage-dot-app { background: var(--text-faint); }
|
||||
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
|
||||
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
|
||||
|
||||
/* ── Migration banner ───────────────────────────────────────── */
|
||||
@@ -2263,9 +2344,6 @@
|
||||
.folder-row:hover { background: var(--bg-raised); }
|
||||
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
||||
.folder-hidden { opacity: 0.35; }
|
||||
.folder-default-active { color: var(--accent-fg) !important; }
|
||||
@@ -2360,13 +2438,14 @@
|
||||
.tracker-connected-btns { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||
.oauth-flow { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; align-items: flex-end; }
|
||||
.oauth-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); text-align: right; }
|
||||
.oauth-hint strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.oauth-hint code { font-family: monospace; font-size: 10px; color: var(--text-muted); background: var(--bg-overlay); padding: 1px 4px; border-radius: 3px; }
|
||||
.oauth-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 10px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||
.oauth-input:focus { border-color: var(--border-focus); }
|
||||
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sec-banner { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); border-radius: var(--radius-sm); }
|
||||
.sec-banner { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); border-radius: var(--radius-sm); line-height: var(--leading-snug); }
|
||||
.sec-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
|
||||
.sec-banner-warn { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
|
||||
.sec-banner-warn code { font-family: monospace; font-size: 10px; background: color-mix(in srgb, var(--color-error) 12%, transparent); padding: 1px 4px; border-radius: 3px; }
|
||||
.sec-pill-warn { border-color: var(--color-error); color: var(--color-error); background: var(--color-error-bg); }
|
||||
.section-title-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
|
||||
.section-title-row .section-title { padding: 0; }
|
||||
.sec-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); flex-shrink: 0; cursor: default; }
|
||||
@@ -2379,14 +2458,15 @@
|
||||
.sec-action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.sec-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-mode-group { display: flex; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: 2px; flex-shrink: 0; }
|
||||
.auth-mode-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); white-space: nowrap; }
|
||||
.auth-mode-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.auth-mode-btn.auth-mode-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.auth-mode-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.sec-action-primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.sec-action-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
|
||||
.sec-action-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
||||
.sec-pin-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.sec-pin-wrap .sec-pin-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sec-pin-input { width: 96px; text-align: center; letter-spacing: 0.25em; }
|
||||
.sec-port-input { width: 88px; }
|
||||
.sec-pin-error { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
let { editingId = $bindable(null), onClose }: Props = $props();
|
||||
|
||||
// ── Token group definitions ───────────────────────────────────────────────
|
||||
|
||||
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
||||
{
|
||||
label: "Backgrounds",
|
||||
@@ -65,8 +63,6 @@
|
||||
"color-info-bg": "Info background",
|
||||
};
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
||||
if (editingId) {
|
||||
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
||||
@@ -81,13 +77,10 @@
|
||||
let saveStatus: "idle" | "saved" = $state("idle");
|
||||
let importError: string | null = $state(null);
|
||||
|
||||
// ── CSS vars helper ───────────────────────────────────────────────────────
|
||||
function toCssVars(t: ThemeTokens): string {
|
||||
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
function handleSave() {
|
||||
const name = themeName.trim() || "Untitled Theme";
|
||||
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
||||
@@ -154,17 +147,15 @@
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<!-- ── Main editor ────────────────────────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="te-backdrop" onclick={onClose} role="presentation">
|
||||
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||
<div
|
||||
class="te-shell"
|
||||
role="dialog"
|
||||
aria-label="Theme editor"
|
||||
tabindex="0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────── -->
|
||||
<header class="te-header">
|
||||
<div class="te-header-left">
|
||||
<button class="te-icon-btn" onclick={onClose} title="Close editor">
|
||||
@@ -209,25 +200,17 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Body ───────────────────────────────────────────────────────── -->
|
||||
<div class="te-body">
|
||||
|
||||
<!-- Left: live preview -->
|
||||
<aside class="te-preview-pane">
|
||||
<div class="te-pane-label">Live Preview</div>
|
||||
|
||||
<!--
|
||||
FIX 1: toCssVars scoped only to this element, so only the
|
||||
preview UI sees the draft tokens — not the editor shell.
|
||||
-->
|
||||
<div class="te-preview-ui" style={toCssVars(tokens)}>
|
||||
<!-- Sidebar -->
|
||||
<div class="prv-sidebar">
|
||||
{#each [true, false, false, false] as active}
|
||||
<div class="prv-sb-dot" class:active></div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Main -->
|
||||
<div class="prv-main">
|
||||
<div class="prv-titlebar">
|
||||
<div class="prv-win-dots">
|
||||
@@ -262,7 +245,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swatch strip — scoped to draft tokens too -->
|
||||
<div class="te-swatches" style={toCssVars(tokens)}>
|
||||
{#each [
|
||||
["bg-base","bg-base"],["bg-surface","bg-surface"],
|
||||
@@ -279,7 +261,6 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: token editor -->
|
||||
<div class="te-editor-pane">
|
||||
{#each TOKEN_GROUPS as group}
|
||||
<div class="te-group">
|
||||
@@ -287,7 +268,14 @@
|
||||
<div class="te-token-list">
|
||||
{#each group.tokens as token}
|
||||
<div class="te-token-row">
|
||||
<span class="te-color-swatch" style="background: {tokens[token]}"></span>
|
||||
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
|
||||
<input
|
||||
type="color"
|
||||
class="te-color-picker"
|
||||
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
|
||||
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
|
||||
/>
|
||||
</label>
|
||||
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
|
||||
<span class="te-token-key">{token}</span>
|
||||
<input
|
||||
@@ -318,20 +306,16 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
|
||||
.te-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: 200;
|
||||
/* FIX 2: center the modal instead of stretch */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: teBackdropIn 0.14s ease both;
|
||||
}
|
||||
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────────────── */
|
||||
.te-shell {
|
||||
/* FIX 2: constrained dimensions so it doesn't fill the screen */
|
||||
width: calc(100% - 48px);
|
||||
max-width: 1100px;
|
||||
height: calc(100% - 48px);
|
||||
@@ -348,7 +332,6 @@
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.te-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 0 16px; height: 46px;
|
||||
@@ -420,10 +403,8 @@
|
||||
.te-save-btn:hover { filter: brightness(1.12); }
|
||||
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
||||
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
||||
|
||||
/* ── Preview pane ─────────────────────────────────────────────────────────── */
|
||||
.te-preview-pane {
|
||||
width: 260px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
@@ -439,7 +420,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* te-preview-ui receives draft CSS vars via inline style */
|
||||
.te-preview-ui {
|
||||
flex: 1; min-height: 0;
|
||||
border-radius: 8px; overflow: hidden;
|
||||
@@ -447,7 +427,6 @@
|
||||
display: flex; background: var(--bg-void);
|
||||
}
|
||||
|
||||
/* Sidebar strip */
|
||||
.prv-sidebar {
|
||||
width: 34px; flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
@@ -512,7 +491,6 @@
|
||||
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
.prv-toast-lines { flex: 1; }
|
||||
|
||||
/* Swatch strip */
|
||||
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.te-swatch {
|
||||
width: 22px; height: 22px; border-radius: 4px;
|
||||
@@ -520,7 +498,6 @@
|
||||
flex-shrink: 0; cursor: default;
|
||||
}
|
||||
|
||||
/* ── Editor pane ──────────────────────────────────────────────────────────── */
|
||||
.te-editor-pane {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
@@ -552,10 +529,23 @@
|
||||
.te-token-row:hover { background: var(--bg-raised); }
|
||||
|
||||
.te-color-swatch {
|
||||
width: 16px; height: 16px; border-radius: 4px;
|
||||
width: 36px; height: 18px; border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
|
||||
|
||||
.te-color-picker {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0; border: none;
|
||||
}
|
||||
|
||||
.te-token-name {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { store, updateSettings } from "../../store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
|
||||
import type { MangaPrefs } from "../../store/state.svelte";
|
||||
import type { Chapter } from "../../lib/types";
|
||||
|
||||
let { mangaId, chapters, onClose }: {
|
||||
mangaId: number;
|
||||
chapters: Chapter[];
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const mangaPrefs = $derived(
|
||||
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
|
||||
);
|
||||
|
||||
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
|
||||
updateSettings({
|
||||
mangaPrefs: {
|
||||
...store.settings.mangaPrefs,
|
||||
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const scanlators = $derived(
|
||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
];
|
||||
|
||||
const MAX_KEEP_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
{ value: 25, label: "25" },
|
||||
];
|
||||
|
||||
const DELETE_DELAY_OPTIONS = [
|
||||
{ value: 0, label: "Now" },
|
||||
{ value: 24, label: "1 day" },
|
||||
{ value: 168, label: "1 week" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL_OPTIONS = [
|
||||
{ value: "global", label: "Default" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">Per-series rules</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="section-label">Downloads</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Auto-download new chapters</span>
|
||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("autoDownload")}
|
||||
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Download ahead</span>
|
||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("downloadAhead") === opt.value}
|
||||
onclick={() => setPref("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Max chapters to keep</span>
|
||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
|
||||
onclick={() => setPref("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">On Read</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Delete after reading</span>
|
||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("deleteOnRead")}
|
||||
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if getPref("deleteOnRead")}
|
||||
<div class="auto-row auto-row-sub">
|
||||
<span class="auto-label">Delete delay</span>
|
||||
<div class="auto-chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("deleteDelayHours") === opt.value}
|
||||
onclick={() => setPref("deleteDelayHours", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Updates</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Pause updates</span>
|
||||
<span class="auto-desc">Skip this series during global refresh</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getPref("pauseUpdates")}
|
||||
aria-label="Pause updates"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={getPref("pauseUpdates")}
|
||||
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Refresh interval</span>
|
||||
<span class="auto-desc">How often to check for new chapters</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={getPref("refreshInterval") === opt.value}
|
||||
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if scanlators.length > 1}
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Scanlator</p>
|
||||
|
||||
<div class="auto-row auto-row-align-start">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Preferred scanlator</span>
|
||||
<span class="auto-desc">Prioritise this group's chapters in the list</span>
|
||||
</div>
|
||||
<div class="scanlator-list">
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={!getPref("preferredScanlator")}
|
||||
onclick={() => setPref("preferredScanlator", "")}
|
||||
>Any</button>
|
||||
{#each scanlators as s}
|
||||
<button
|
||||
class="auto-chip scanlator-chip"
|
||||
class:auto-chip-on={getPref("preferredScanlator") === s}
|
||||
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
|
||||
title={s}
|
||||
>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||
max-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* Body */
|
||||
.modal-body {
|
||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
.modal-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Section labels */
|
||||
.section-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-widest); color: var(--text-faint);
|
||||
text-transform: uppercase; margin: 0;
|
||||
}
|
||||
|
||||
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
/* Rows — mirrors SeriesDetail auto-row */
|
||||
.auto-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.auto-row-align-start { align-items: flex-start; }
|
||||
.auto-row-sub {
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 2px solid var(--border-dim);
|
||||
}
|
||||
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
|
||||
/* Toggle */
|
||||
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||
|
||||
/* Chips */
|
||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
/* Scanlator list */
|
||||
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
|
||||
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -393,19 +393,18 @@
|
||||
|
||||
{#if !loadingDetail}
|
||||
<div class="meta-table">
|
||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
||||
{#if !loadingChapters && firstUpload && lastUpload}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Published</span>
|
||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-col">
|
||||
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
||||
<div class="meta-col">
|
||||
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
|
||||
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -528,9 +527,11 @@
|
||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
|
||||
.meta-col { display: flex; flex-direction: column; }
|
||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||
.meta-link:hover { opacity: 0.75; }
|
||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
|
||||
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
|
||||
import type { Chapter } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
mangaId: number;
|
||||
chapters: Chapter[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { mangaId, chapters, onClose }: Props = $props();
|
||||
|
||||
const COLOR_HEX: Record<MarkerColor, string> = {
|
||||
yellow: "#c4a94a",
|
||||
red: "#c47a7a",
|
||||
blue: "#7a9ec4",
|
||||
green: "#7aab7a",
|
||||
purple: "#a07ac4",
|
||||
};
|
||||
|
||||
const markers = $derived(store.getMarkersForManga(mangaId));
|
||||
|
||||
const grouped = $derived.by(() => {
|
||||
const map = new Map<number, MarkerEntry[]>();
|
||||
for (const m of markers) {
|
||||
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
|
||||
map.get(m.chapterId)!.push(m);
|
||||
}
|
||||
const entries = [...map.entries()].map(([chapterId, items]) => ({
|
||||
chapterId,
|
||||
chapterName: items[0].chapterName,
|
||||
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
|
||||
}));
|
||||
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
|
||||
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
|
||||
return entries;
|
||||
});
|
||||
|
||||
let editingId: string = $state("");
|
||||
let editNote: string = $state("");
|
||||
let editColor: MarkerColor = $state("yellow");
|
||||
|
||||
function startEdit(m: MarkerEntry) {
|
||||
editingId = m.id;
|
||||
editNote = m.note;
|
||||
editColor = m.color;
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (!editingId) return;
|
||||
updateMarker(editingId, { note: editNote.trim(), color: editColor });
|
||||
editingId = "";
|
||||
}
|
||||
|
||||
function jumpToMarker(m: MarkerEntry) {
|
||||
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||
if (!chapter) return;
|
||||
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
openReader(chapter, chaptersAsc);
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<MapPin size={13} weight="fill" />
|
||||
<span>Markers</span>
|
||||
{#if markers.length > 0}
|
||||
<span class="count">{markers.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if grouped.length === 0}
|
||||
<div class="empty">
|
||||
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
|
||||
<p>No markers yet</p>
|
||||
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group}
|
||||
<div class="group">
|
||||
<div class="group-header">
|
||||
<span class="group-name">{group.chapterName}</span>
|
||||
<span class="group-count">{group.items.length}</span>
|
||||
</div>
|
||||
{#each group.items as m (m.id)}
|
||||
<div class="marker-row" class:editing={editingId === m.id}>
|
||||
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
|
||||
<div class="marker-body">
|
||||
{#if editingId === m.id}
|
||||
<div class="edit-wrap">
|
||||
<div class="color-row">
|
||||
{#each Object.entries(COLOR_HEX) as [c, hex]}
|
||||
<button
|
||||
class="color-swatch"
|
||||
class:color-active={editColor === c}
|
||||
style="background:{hex}"
|
||||
onclick={() => editColor = c as MarkerColor}
|
||||
title={c}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
class="edit-input"
|
||||
rows={3}
|
||||
bind:value={editNote}
|
||||
placeholder="Add a note…"
|
||||
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
|
||||
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
|
||||
<span class="page-label">p.{m.pageNumber}</span>
|
||||
{#if m.note}
|
||||
<span class="marker-note">{m.note}</span>
|
||||
{:else}
|
||||
<span class="marker-note marker-note-empty">No note</span>
|
||||
{/if}
|
||||
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
|
||||
</button>
|
||||
<div class="marker-actions">
|
||||
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
|
||||
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
|
||||
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
|
||||
|
||||
.group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
|
||||
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
|
||||
|
||||
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.marker-row:hover { background: var(--bg-raised); }
|
||||
.marker-row.editing { background: var(--bg-raised); }
|
||||
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||
|
||||
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
|
||||
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
|
||||
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
|
||||
.marker-note-empty { color: var(--text-faint); font-style: italic; }
|
||||
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
|
||||
.marker-row:hover .marker-actions { opacity: 1; }
|
||||
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.color-row { display: flex; gap: 5px; }
|
||||
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.color-swatch:hover { transform: scale(1.15); }
|
||||
.color-active { border-color: var(--text-primary) !important; }
|
||||
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
|
||||
.edit-input:focus { border-color: var(--border-focus); }
|
||||
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||
.edit-save:hover { filter: brightness(1.15); }
|
||||
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
</style>
|
||||
@@ -19,8 +19,6 @@
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = "records" | number;
|
||||
|
||||
let trackers: Tracker[] = $state([]);
|
||||
@@ -39,8 +37,6 @@
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -61,7 +57,6 @@
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// Auto-search with manga title when switching to a tracker tab
|
||||
$effect(() => {
|
||||
const tab = activeTab;
|
||||
if (typeof tab !== "number") return;
|
||||
@@ -71,14 +66,10 @@
|
||||
doSearch(tab, mangaTitle);
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function onSearchInput() {
|
||||
@@ -105,8 +96,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bind / Unbind ──────────────────────────────────────────────────────────
|
||||
|
||||
async function bind(result: TrackSearch) {
|
||||
if (typeof activeTab !== "number") return;
|
||||
binding = true;
|
||||
@@ -137,8 +126,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateStatus(record: TrackRecord, status: number) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
@@ -234,7 +221,6 @@
|
||||
>
|
||||
<div class="modal" role="dialog" aria-label="Tracking">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Tracking</span>
|
||||
@@ -256,7 +242,6 @@
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
@@ -282,7 +267,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── My List tab ───────────────────────────────────────────────────── -->
|
||||
{#if activeTab === "records"}
|
||||
<div class="tab-body">
|
||||
{#if records.length === 0}
|
||||
@@ -434,7 +418,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
{@const tracker = trackerFor(activeTab as number)}
|
||||
{@const boundRecord = recordFor(activeTab as number)}
|
||||
@@ -517,8 +500,8 @@
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(580px, calc(100vw - 48px));
|
||||
max-height: min(680px, calc(100vh - 80px));
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
max-height: min(660px, calc(100vh - 80px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
@@ -532,9 +515,9 @@
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* States */
|
||||
@@ -544,71 +527,93 @@
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab-active { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; }
|
||||
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
||||
.tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2); position: relative;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 10px 10px 9px; color: var(--text-faint);
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
|
||||
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
|
||||
/* Records */
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tab-body::-webkit-scrollbar { display: none; }
|
||||
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
|
||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
.record-row {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.record-row:hover { background: var(--bg-overlay); }
|
||||
.record-busy { opacity: 0.45; pointer-events: none; }
|
||||
|
||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; }
|
||||
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
|
||||
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
|
||||
.record-title:hover { opacity: 0.75; }
|
||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center;
|
||||
transition: border-color var(--t-base);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
||||
.record-select:focus { border-color: var(--accent); outline: none; }
|
||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
||||
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
|
||||
.record-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 100px; }
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
||||
.record-select-score { max-width: 90px; }
|
||||
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.record-icon-btn.icon-active { color: var(--accent-fg); }
|
||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); }
|
||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
|
||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
||||
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
|
||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
@@ -618,17 +623,16 @@
|
||||
/* Results */
|
||||
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||
.result-bound { background: var(--accent-muted) !important; }
|
||||
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.result-row:disabled { opacity: 0.4; cursor: default; }
|
||||
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.result-cover-empty { background: var(--bg-raised); }
|
||||
.hidden { display: none; }
|
||||
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
||||
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
||||
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { store, updateSettings } from "../store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
const headers = user && pass ? basicHeader(user, pass) : {};
|
||||
return fetch(url, buildRequestInit({ ...init, signal }, headers));
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") {
|
||||
updateSettings({ serverAuthMode: "NONE" });
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
return "auth_required";
|
||||
}
|
||||
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
+29
-22
@@ -1,4 +1,5 @@
|
||||
import { store } from "../store/state.svelte";
|
||||
import { fetchAuthenticated } from "./auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
@@ -7,25 +8,31 @@ function getServerUrl(): string {
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function getAuthHeader(): Record<string, string> {
|
||||
const s = store.settings;
|
||||
if (!s.serverAuthEnabled) return {};
|
||||
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
|
||||
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
|
||||
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
|
||||
const base = getServerUrl();
|
||||
const mode = store.settings.serverAuthMode;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) {
|
||||
const url = new URL(`${base}${path}`);
|
||||
url.username = user;
|
||||
url.password = pass;
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
@@ -41,22 +48,22 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
if (i === retries - 1) throw e;
|
||||
@@ -67,14 +74,14 @@ async function fetchWithRetry(
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Keybinds {
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
toggleBookmark: string;
|
||||
toggleMarker: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
toggleBookmark: "m",
|
||||
toggleMarker: "n",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
@@ -43,6 +45,7 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Open settings",
|
||||
toggleBookmark: "Toggle bookmark",
|
||||
toggleMarker: "Toggle marker",
|
||||
};
|
||||
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
|
||||
+19
-2
@@ -441,8 +441,8 @@ export const GET_SOURCES = `
|
||||
`;
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||
mangas {
|
||||
id
|
||||
title
|
||||
@@ -888,3 +888,20 @@ export const LOGOUT_TRACKER = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken {
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -81,6 +81,29 @@ export function shouldHideNsfw(
|
||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Blocked list → always hidden, even when showNsfw is on.
|
||||
* 2. Allowed list → always shown, even if isNsfw is true.
|
||||
* 3. Fallback → hide when showNsfw is off and source.isNsfw is true.
|
||||
*
|
||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||
*/
|
||||
export function shouldHideSource(
|
||||
source: { id: string; isNsfw: boolean },
|
||||
settings: {
|
||||
showNsfw: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
},
|
||||
): boolean {
|
||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||
return !settings.showNsfw && source.isNsfw;
|
||||
}
|
||||
|
||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||
|
||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||
|
||||
+169
-186
@@ -27,35 +27,38 @@ export type LibraryStatusFilter =
|
||||
| "CANCELLED"
|
||||
| "HIATUS"
|
||||
| "UNKNOWN";
|
||||
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
|
||||
|
||||
export type LibraryContentFilter =
|
||||
| "unread"
|
||||
| "started"
|
||||
| "downloaded"
|
||||
| "bookmarked"
|
||||
| "marked";
|
||||
|
||||
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
export type Theme = BuiltinTheme | string;
|
||||
|
||||
export interface ThemeTokens {
|
||||
/* Backgrounds */
|
||||
"bg-void": string;
|
||||
"bg-base": string;
|
||||
"bg-surface": string;
|
||||
"bg-raised": string;
|
||||
"bg-overlay": string;
|
||||
"bg-subtle": string;
|
||||
/* Borders */
|
||||
"border-dim": string;
|
||||
"border-base": string;
|
||||
"border-strong": string;
|
||||
"border-focus": string;
|
||||
/* Text */
|
||||
"text-primary": string;
|
||||
"text-secondary": string;
|
||||
"text-muted": string;
|
||||
"text-faint": string;
|
||||
"text-disabled": string;
|
||||
/* Accent */
|
||||
"accent": string;
|
||||
"accent-dim": string;
|
||||
"accent-muted": string;
|
||||
"accent-fg": string;
|
||||
"accent-bright": string;
|
||||
/* Semantic */
|
||||
"color-error": string;
|
||||
"color-error-bg": string;
|
||||
"color-success": string;
|
||||
@@ -64,7 +67,7 @@ export interface ThemeTokens {
|
||||
}
|
||||
|
||||
export interface CustomTheme {
|
||||
id: string; // "custom:abc123"
|
||||
id: string;
|
||||
name: string;
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
@@ -97,7 +100,6 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
||||
"color-info-bg": "#121a1f",
|
||||
};
|
||||
|
||||
|
||||
export interface HistoryEntry {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
@@ -115,21 +117,29 @@ export interface BookmarkEntry {
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
savedAt: number;
|
||||
/** Optional user label, e.g. "before the fight scene" */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadLogEntry — append-only record of every chapter-completion event.
|
||||
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
||||
* this log never overwrites existing entries. It is the source of truth
|
||||
* for all reading stats.
|
||||
*/
|
||||
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
|
||||
|
||||
export interface MarkerEntry {
|
||||
id: string;
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
note: string;
|
||||
color: MarkerColor;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface ReadLogEntry {
|
||||
mangaId: number;
|
||||
chapterId: number;
|
||||
readAt: number;
|
||||
/** Minutes spent on this chapter (estimated from page count or default). */
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
@@ -144,7 +154,7 @@ export interface ReadingStats {
|
||||
lastStreakDate: string;
|
||||
}
|
||||
|
||||
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available
|
||||
const AVG_MIN_PER_CHAPTER = 5;
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
totalChaptersRead: 0,
|
||||
@@ -171,16 +181,32 @@ export interface ActiveDownload {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface MangaPrefs {
|
||||
autoDownload: boolean;
|
||||
downloadAhead: number;
|
||||
deleteOnRead: boolean;
|
||||
deleteDelayHours: number;
|
||||
maxKeepChapters: number;
|
||||
pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
maxKeepChapters: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "global",
|
||||
preferredScanlator: "",
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
/**
|
||||
* Reader zoom level — unitless float multiplier relative to the viewer
|
||||
* container width. 1.0 = image fills the viewer, 1.5 = 150%, 0.8 = 80%.
|
||||
* Replaces the old `maxPageWidth` pixel value.
|
||||
*/
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
@@ -195,11 +221,6 @@ export interface Settings {
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterSortMode: ChapterSortMode;
|
||||
chapterPageSize: number;
|
||||
/**
|
||||
* UI zoom level — unitless float multiplier applied on top of the
|
||||
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
|
||||
* Replaces the old `uiScale` percentage integer.
|
||||
*/
|
||||
uiZoom: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
@@ -219,9 +240,10 @@ export interface Settings {
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
mangaLinks: Record<number, number[]>;
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||
serverAuthUser: string;
|
||||
serverAuthPass: string;
|
||||
serverAuthEnabled: boolean;
|
||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
socksProxyEnabled: boolean;
|
||||
socksProxyHost: string;
|
||||
socksProxyPort: string;
|
||||
@@ -238,34 +260,20 @@ export interface Settings {
|
||||
appLockPin: string;
|
||||
customThemes: CustomTheme[];
|
||||
hiddenCategoryIds: number[];
|
||||
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
|
||||
defaultLibraryCategoryId: number | null;
|
||||
/**
|
||||
* Content filtering — managed via the Content tab in Settings.
|
||||
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive).
|
||||
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true.
|
||||
* nsfwBlockedSourceIds: sources always blocked regardless of tag content.
|
||||
*/
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
|
||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||
// Legacy fields kept for migration reads only — never written after v3.
|
||||
/** @deprecated use readerZoom */
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||
maxPageWidth?: number;
|
||||
/** @deprecated use uiZoom */
|
||||
uiScale?: number;
|
||||
/** User-added extra directories to include when scanning storage usage. */
|
||||
extraScanDirs: string[];
|
||||
/** Cached downloads path from Suwayomi, kept in sync on storage tab load. */
|
||||
serverDownloadsPath: string;
|
||||
/** Cached local source path from Suwayomi, kept in sync on storage tab load. */
|
||||
serverLocalSourcePath: string;
|
||||
}
|
||||
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
pageStyle: "longstrip",
|
||||
readingDirection: "ltr",
|
||||
@@ -303,9 +311,10 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
mangaLinks: {},
|
||||
mangaPrefs: {},
|
||||
serverAuthUser: "",
|
||||
serverAuthPass: "",
|
||||
serverAuthEnabled: false,
|
||||
serverAuthMode: "NONE",
|
||||
socksProxyEnabled: false,
|
||||
socksProxyHost: "",
|
||||
socksProxyPort: "1080",
|
||||
@@ -328,17 +337,14 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
nsfwBlockedSourceIds: [],
|
||||
libraryTabSort: {},
|
||||
libraryTabStatus: {},
|
||||
libraryTabFilters: {},
|
||||
extraScanDirs: [],
|
||||
serverDownloadsPath: "",
|
||||
serverLocalSourcePath: "",
|
||||
};
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
|
||||
const STORE_VERSION = 3;
|
||||
|
||||
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
||||
// Add a key here whenever its default changes meaning between releases.
|
||||
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
||||
"serverBinary",
|
||||
"readerZoom",
|
||||
@@ -386,13 +392,15 @@ function mergeSettings(saved: any): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...saved?.settings,
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||
customThemes: saved?.settings?.customThemes ?? [],
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||
customThemes: saved?.settings?.customThemes ?? [],
|
||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||
@@ -410,32 +418,16 @@ function todayStr(): string {
|
||||
|
||||
const genId = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Store {
|
||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||
libraryFilter: LibraryFilter = $state("library");
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
/**
|
||||
* readLog — append-only, never deduped. Every chapter completion/progress
|
||||
* event lands here. This is the authoritative source for all reading stats.
|
||||
* Capped at 5 000 entries; oldest are trimmed first.
|
||||
*/
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
/**
|
||||
* bookmarks — user-placed markers at a specific page in a chapter.
|
||||
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
|
||||
*/
|
||||
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||
settings: Settings = $state(mergeSettings(saved));
|
||||
|
||||
/**
|
||||
* Bumped each time the reader closes. Home.svelte watches this to know
|
||||
* when to re-fetch library data and refresh the hero section.
|
||||
*/
|
||||
readerSessionId: number = $state(0);
|
||||
|
||||
genreFilter: string = $state("");
|
||||
searchPrefill: string = $state("");
|
||||
activeManga: Manga | null = $state(null);
|
||||
@@ -449,39 +441,27 @@ class Store {
|
||||
toasts: Toast[] = $state([]);
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
||||
isFullscreen: boolean = $state(false);
|
||||
|
||||
// ── Shared category list ──────────────────────────────────────────────────
|
||||
// Single source of truth for the category list, shared between Library and
|
||||
// Settings. Library owns fetching; Settings reads and mutates in-place.
|
||||
// No pub/sub or guard flags needed — both components share this $state ref.
|
||||
categories: Category[] = $state([]);
|
||||
|
||||
// ── Discover session cache ────────────────────────────────────────────────
|
||||
// Survives navigation within a session but is never persisted to localStorage.
|
||||
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
||||
categories: Category[] = $state([]);
|
||||
discoverCache: Map<string, Manga[]> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
||||
$effect(() => { persist({ storeVersion: STORE_VERSION }); });
|
||||
$effect(() => { persist({ navPage: this.navPage }); });
|
||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||
$effect(() => { persist({ history: this.history }); });
|
||||
$effect(() => { persist({ readLog: this.readLog }); });
|
||||
$effect(() => { persist({ bookmarks: this.bookmarks }); });
|
||||
$effect(() => { persist({ markers: this.markers }); });
|
||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||
$effect(() => { persist({ settings: this.settings }); });
|
||||
});
|
||||
}
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
// Always set activeManga when provided so the Reader has full manga
|
||||
// context for Discord RPC (setReading) and any other manga-aware logic.
|
||||
// Callers that already set store.activeManga directly may omit this arg.
|
||||
if (manga) this.activeManga = manga;
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
@@ -490,36 +470,20 @@ class Store {
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
// Null activeChapter FIRST so the history $effect in Reader can't fire
|
||||
// one last time with stale chapter + pageNumber=1, overwriting the real
|
||||
// last-read position with page 1.
|
||||
this.activeChapter = null;
|
||||
this.activeChapterList = [];
|
||||
this.pageUrls = [];
|
||||
this.pageNumber = 1;
|
||||
this.readerSessionId += 1; // signals Home to refresh
|
||||
this.readerSessionId += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reading event.
|
||||
*
|
||||
* @param entry - The history entry for the "continue reading" UI.
|
||||
* @param completed - True when the chapter was fully read (triggers stat
|
||||
* accrual). False for mid-chapter progress updates.
|
||||
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
|
||||
*/
|
||||
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
|
||||
// ── 1. Update the deduped "continue reading" history ──────────────────
|
||||
// Always keep the latest position for each chapter at the top.
|
||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
||||
this.history[0] = { ...this.history[0], readAt: entry.readAt };
|
||||
} else {
|
||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||
}
|
||||
|
||||
// ── 2. Append to the read log (only on completion) ────────────────────
|
||||
// This is append-only — every completed chapter read lands here,
|
||||
// including re-reads. We cap at 5 000 to keep storage bounded.
|
||||
if (completed) {
|
||||
const logEntry: ReadLogEntry = {
|
||||
mangaId: entry.mangaId,
|
||||
@@ -530,12 +494,7 @@ class Store {
|
||||
this.readLog = [...this.readLog, logEntry].slice(-5000);
|
||||
}
|
||||
|
||||
// ── 3. Recompute stats from the read log ──────────────────────────────
|
||||
// Use the log as ground truth so stats are always accurate even after
|
||||
// history is cleared or entries are back-filled.
|
||||
const log = completed
|
||||
? [...this.readLog] // already updated above
|
||||
: this.readLog;
|
||||
const log = completed ? [...this.readLog] : this.readLog;
|
||||
|
||||
const uniqueChapters = new Set(log.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(log.map(e => e.mangaId));
|
||||
@@ -564,15 +523,10 @@ class Store {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a bookmark for the given chapter/page. Only one bookmark
|
||||
* per chapter is kept — adding a second one replaces the first.
|
||||
*/
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
|
||||
this.bookmarks = [
|
||||
bookmark,
|
||||
// Keep bookmarks from other manga only — one bookmark per manga at a time
|
||||
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
@@ -589,11 +543,44 @@ class Store {
|
||||
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||
}
|
||||
|
||||
clearHistory() { this.history = []; this.readLog = []; }
|
||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||
const id = genId();
|
||||
const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() };
|
||||
this.markers = [marker, ...this.markers].slice(0, 2000);
|
||||
return id;
|
||||
}
|
||||
|
||||
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||
this.markers = this.markers.map(m =>
|
||||
m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m
|
||||
);
|
||||
}
|
||||
|
||||
removeMarker(id: string) {
|
||||
this.markers = this.markers.filter(m => m.id !== id);
|
||||
}
|
||||
|
||||
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||
}
|
||||
|
||||
getMarkersForChapter(chapterId: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.chapterId === chapterId);
|
||||
}
|
||||
|
||||
getMarkersForManga(mangaId: number): MarkerEntry[] {
|
||||
return this.markers.filter(m => m.mangaId === mangaId);
|
||||
}
|
||||
|
||||
clearMarkersForManga(mangaId: number) {
|
||||
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
|
||||
}
|
||||
|
||||
clearHistory() { this.history = []; this.readLog = []; }
|
||||
|
||||
clearHistoryForManga(mangaId: number) {
|
||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||
// Recompute stats after removal
|
||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||
@@ -608,11 +595,11 @@ class Store {
|
||||
wipeAllData() {
|
||||
this.history = [];
|
||||
this.readLog = [];
|
||||
this.markers = [];
|
||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||
}
|
||||
|
||||
|
||||
linkManga(idA: number, idB: number) {
|
||||
if (idA === idB) return;
|
||||
const links = { ...this.settings.mangaLinks };
|
||||
@@ -630,7 +617,7 @@ class Store {
|
||||
this.settings = { ...this.settings, mangaLinks: links };
|
||||
}
|
||||
|
||||
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
||||
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
||||
|
||||
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
||||
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
||||
@@ -642,23 +629,22 @@ class Store {
|
||||
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||
}
|
||||
|
||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||
setCategories(cats: Category[]) { this.categories = cats; }
|
||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||
setNavPage(next: NavPage) { this.navPage = next; }
|
||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||
setActiveSource(next: Source | null) { this.activeSource = next; }
|
||||
setPageUrls(next: string[]) { this.pageUrls = next; }
|
||||
setPageNumber(next: number) { this.pageNumber = next; }
|
||||
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||
|
||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||
setCategories(cats: Category[]) { this.categories = cats; }
|
||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||
setNavPage(next: NavPage) { this.navPage = next; }
|
||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||
setActiveSource(next: Source | null) { this.activeSource = next; }
|
||||
setPageUrls(next: string[]) { this.pageUrls = next; }
|
||||
setPageNumber(next: number) { this.pageNumber = next; }
|
||||
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||
|
||||
saveCustomTheme(theme: CustomTheme) {
|
||||
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
||||
@@ -674,13 +660,6 @@ class Store {
|
||||
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-assign or remove the "Completed" category for a manga based on
|
||||
* whether all chapters are read. Pass the `gql` executor to avoid a
|
||||
* circular import between state.svelte.ts and client.ts.
|
||||
*
|
||||
* Call after any batch mark-read/unread operation.
|
||||
*/
|
||||
async checkAndMarkCompleted(
|
||||
mangaId: number,
|
||||
chaps: Chapter[],
|
||||
@@ -695,7 +674,6 @@ class Store {
|
||||
if (!completed) return;
|
||||
if (allRead) {
|
||||
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
||||
// Ensure the manga is in the library so it shows up in the Saved tab
|
||||
if (UPDATE_MANGA) {
|
||||
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||
}
|
||||
@@ -719,43 +697,48 @@ class Store {
|
||||
|
||||
export const store = new Store();
|
||||
|
||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
||||
|
||||
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||
export function closeReader() { store.closeReader(); }
|
||||
export function closeReader() { store.closeReader(); }
|
||||
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||
export function clearHistory() { store.clearHistory(); }
|
||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||
export function wipeAllData() { store.wipeAllData(); }
|
||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
||||
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
||||
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
||||
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
||||
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
||||
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
export function clearHistory() { store.clearHistory(); }
|
||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||
export function wipeAllData() { store.wipeAllData(); }
|
||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
||||
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
||||
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
||||
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
||||
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
||||
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { store.clearBookmarks(); }
|
||||
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { store.clearBookmarks(); }
|
||||
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
|
||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
|
||||
export function removeMarker(id: string) { store.removeMarker(id); }
|
||||
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
|
||||
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
|
||||
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
|
||||
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
|
||||
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||
export async function checkAndMarkCompleted(
|
||||
mangaId: number,
|
||||
chaps: Chapter[],
|
||||
|
||||
Reference in New Issue
Block a user