Compare commits

...

22 Commits

Author SHA1 Message Date
Youwes09 db44afc4dc Chore: Bump versions for 0.7.0 2026-04-02 23:28:16 -05:00
Youwes09 4248e344ab Chore: Embed AuthURL into Images 2026-04-02 23:19:41 -05:00
Youwes09 8941bfef10 Feat: Improved ThemeEditor with ColorPicker (WIP) 2026-04-02 22:50:19 -05:00
Youwes09 11cd6ff870 Fix: CSS Issues 2026-04-02 22:46:55 -05:00
Youwes09 15adb02be3 Fix: Revise Authentication Methods & Add Edge-Case Handling for Auth 2026-04-02 22:27:39 -05:00
Youwes09 51bb6cdab9 Feat: Markers 2026-04-02 18:07:49 -05:00
Youwes09 454a674ada Merge branch 'main' of github.com:Youwes09/Moku 2026-04-02 11:05:25 -05:00
Youwes09 f146de5c02 Fix: Search Tags + Status (WIP) 2026-04-02 11:05:19 -05:00
Shozikan 04f680c3bb Fix Discord link in README
Updated Discord link in README to new URL.
2026-04-02 09:13:41 -05:00
Youwes09 f49f7e7ac1 Feat: Automation Panel (WIP) & SeriesDetail Additions 2026-04-02 00:56:27 -05:00
Youwes09 a62512bf42 Feat: Filtering in Library 2026-04-01 22:26:29 -05:00
Youwes09 d91ed2e6d1 Chore: Redesign MigrationModal Sources 2026-04-01 15:38:10 -05:00
Youwes09 61e3c4ee2f Chore: Redesign SeriesDetail Elements 2026-04-01 14:25:18 -05:00
Youwes09 9151820843 Fix: TitleBar Issue (WIP) & Allow Sources in Content Settings 2026-04-01 11:09:40 -05:00
Youwes09 63c890dadf Fix: Bookmark Notification Spam 2026-04-01 10:53:18 -05:00
Youwes09 51a33679d5 Chore: Bump for 0.6.7 2026-03-31 23:49:17 -05:00
Youwes09 82f8a9a36b Fix: Forgot Auto-Bookmark Toggle & NSFW On GenreDrill 2026-03-31 22:55:26 -05:00
Youwes09 4decce9a7f Fix: Reworked Bookmark System & Added Double Page (WIP) 2026-03-31 19:46:11 -05:00
Youwes09 a69d5eacc5 Fix: Improved Loading (WIP) 2026-03-31 11:28:00 -05:00
Youwes09 4959722759 Feat: Change Download Directory (WIP) 2026-03-30 23:14:40 -05:00
Youwes09 35ba0171c7 Fix: Zoom Issue & Sidebar Overflow 2026-03-30 00:26:04 -05:00
Youwes09 d26fa50e76 Chore: Bump to 0.6.1 2026-03-30 00:04:54 -05:00
34 changed files with 3799 additions and 2485 deletions
+2 -2
View File
@@ -7,7 +7,7 @@
[![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest) [![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE) [![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE)
[![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/cfncTbJ2) [![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/x97hj8zR72)
</div> </div>
@@ -131,7 +131,7 @@ pnpm tauri:dev
Questions, feedback, or just want to hang out — join the Discord. Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/cfncTbJ2) [![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/x97hj8zR72)
--- ---
+16 -15
View File
@@ -1,18 +1,24 @@
Major Revisions: Major Revisions:
- Contemplate Anime Support, Add Novel Support (Consumet API)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
Minor Revisions: Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive) - 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) - Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks) - Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073) - 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) - 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: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - 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: General/Misc Bugs:
- Fix Highlightable Elements - Fix Highlightable Elements
@@ -21,22 +27,17 @@ General/Misc Bugs:
- Fix Delete-All Crash (Deletes All but Cripples App) - Fix Delete-All Crash (Deletes All but Cripples App)
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?) - Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress:`
In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Fix NSFW Parsing (Appears to not Work???)
- Adjustment in Settings for Theme Editor: - Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
- Patch Color-Picker to Work Properly
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" - Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml - Integrate Download Directory Changes (Settings)
flatpak build-bundle repo moku.flatpak dev.moku.app - Fix Source Allow in Content (Doesn't even work)
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 9e9590cf8c98b07ca774382491b1d8cfcc1f2151afadbf8e23e2abda0c086c11 sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+1 -1
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.6.0"; version = "0.7.0";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
+70 -83
View File
@@ -1952,14 +1952,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hyper/hyper-1.8.1.crate", "url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
"sha256": "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11", "sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
"dest": "cargo/vendor/hyper-1.8.1" "dest": "cargo/vendor/hyper-1.9.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11\", \"files\": {}}", "contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
"dest": "cargo/vendor/hyper-1.8.1", "dest": "cargo/vendor/hyper-1.9.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -2355,14 +2355,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.92.crate", "url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate",
"sha256": "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995", "sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9",
"dest": "cargo/vendor/js-sys-0.3.92" "dest": "cargo/vendor/js-sys-0.3.94"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995\", \"files\": {}}", "contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.92", "dest": "cargo/vendor/js-sys-0.3.94",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -3522,19 +3522,6 @@
"dest": "cargo/vendor/pin-project-lite-0.2.17", "dest": "cargo/vendor/pin-project-lite-0.2.17",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -4617,14 +4604,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.0.crate", "url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate",
"sha256": "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98", "sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26",
"dest": "cargo/vendor/serde_spanned-1.1.0" "dest": "cargo/vendor/serde_spanned-1.1.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98\", \"files\": {}}", "contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}",
"dest": "cargo/vendor/serde_spanned-1.1.0", "dest": "cargo/vendor/serde_spanned-1.1.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5670,14 +5657,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.0+spec-1.1.0.crate", "url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.1+spec-1.1.0.crate",
"sha256": "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f", "sha256": "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7",
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0" "dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f\", \"files\": {}}", "contents": "{\"package\": \"3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7\", \"files\": {}}",
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0", "dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5709,40 +5696,40 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.8+spec-1.1.0.crate", "url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.9+spec-1.1.0.crate",
"sha256": "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c", "sha256": "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5",
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0" "dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c\", \"files\": {}}", "contents": "{\"package\": \"da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0", "dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.0+spec-1.1.0.crate", "url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.1+spec-1.1.0.crate",
"sha256": "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011", "sha256": "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9",
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0" "dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011\", \"files\": {}}", "contents": "{\"package\": \"39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9\", \"files\": {}}",
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0", "dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.0+spec-1.1.0.crate", "url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.1+spec-1.1.0.crate",
"sha256": "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed", "sha256": "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db",
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0" "dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed\", \"files\": {}}", "contents": "{\"package\": \"756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db\", \"files\": {}}",
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0", "dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6203,66 +6190,66 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.115.crate", "url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate",
"sha256": "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a", "sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0",
"dest": "cargo/vendor/wasm-bindgen-0.2.115" "dest": "cargo/vendor/wasm-bindgen-0.2.117"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a\", \"files\": {}}", "contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.115", "dest": "cargo/vendor/wasm-bindgen-0.2.117",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.65.crate", "url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate",
"sha256": "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0", "sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65" "dest": "cargo/vendor/wasm-bindgen-futures-0.4.67"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0\", \"files\": {}}", "contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65", "dest": "cargo/vendor/wasm-bindgen-futures-0.4.67",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.115.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate",
"sha256": "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67", "sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115" "dest": "cargo/vendor/wasm-bindgen-macro-0.2.117"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67\", \"files\": {}}", "contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115", "dest": "cargo/vendor/wasm-bindgen-macro-0.2.117",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.115.crate", "url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate",
"sha256": "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf", "sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115" "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf\", \"files\": {}}", "contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115", "dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.115.crate", "url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate",
"sha256": "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93", "sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115" "dest": "cargo/vendor/wasm-bindgen-shared-0.2.117"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93\", \"files\": {}}", "contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115", "dest": "cargo/vendor/wasm-bindgen-shared-0.2.117",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -6320,14 +6307,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.92.crate", "url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate",
"sha256": "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94", "sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a",
"dest": "cargo/vendor/web-sys-0.3.92" "dest": "cargo/vendor/web-sys-0.3.94"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94\", \"files\": {}}", "contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.92", "dest": "cargo/vendor/web-sys-0.3.94",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -7347,14 +7334,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/winnow/winnow-1.0.0.crate", "url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate",
"sha256": "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8", "sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5",
"dest": "cargo/vendor/winnow-1.0.0" "dest": "cargo/vendor/winnow-1.0.1"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8\", \"files\": {}}", "contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.0", "dest": "cargo/vendor/winnow-1.0.1",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
+91 -97
View File
@@ -1393,7 +1393,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http", "http",
"indexmap 2.13.0", "indexmap 2.13.1",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1502,9 +1502,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.8.1" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1516,7 +1516,6 @@ dependencies = [
"httparse", "httparse",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"pin-utils",
"smallvec", "smallvec",
"tokio", "tokio",
"want", "want",
@@ -1600,12 +1599,13 @@ dependencies = [
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"potential_utf", "potential_utf",
"utf8_iter",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec", "zerovec",
@@ -1613,9 +1613,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_locale_core" name = "icu_locale_core"
version = "2.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"litemap", "litemap",
@@ -1626,9 +1626,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer" name = "icu_normalizer"
version = "2.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [ dependencies = [
"icu_collections", "icu_collections",
"icu_normalizer_data", "icu_normalizer_data",
@@ -1640,15 +1640,15 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer_data" name = "icu_normalizer_data"
version = "2.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]] [[package]]
name = "icu_properties" name = "icu_properties"
version = "2.1.2" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [ dependencies = [
"icu_collections", "icu_collections",
"icu_locale_core", "icu_locale_core",
@@ -1660,15 +1660,15 @@ dependencies = [
[[package]] [[package]]
name = "icu_properties_data" name = "icu_properties_data"
version = "2.1.2" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]] [[package]]
name = "icu_provider" name = "icu_provider"
version = "2.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_locale_core", "icu_locale_core",
@@ -1725,9 +1725,9 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown 0.16.1",
@@ -1854,9 +1854,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.92" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -1905,7 +1905,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [ dependencies = [
"cssparser 0.29.6", "cssparser 0.29.6",
"html5ever 0.29.1", "html5ever 0.29.1",
"indexmap 2.13.0", "indexmap 2.13.1",
"selectors 0.24.0", "selectors 0.24.0",
] ]
@@ -1941,9 +1941,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]] [[package]]
name = "libloading" name = "libloading"
@@ -1975,9 +1975,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "litrs" name = "litrs"
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.6.0" version = "0.7.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
@@ -2123,9 +2123,9 @@ dependencies = [
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dpi", "dpi",
@@ -2773,12 +2773,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -2798,7 +2792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.1",
"quick-xml", "quick-xml",
"serde", "serde",
"time", "time",
@@ -2819,9 +2813,9 @@ dependencies = [
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [ dependencies = [
"zerovec", "zerovec",
] ]
@@ -2883,7 +2877,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit 0.25.8+spec-1.1.0", "toml_edit 0.25.10+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -3707,9 +3701,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.0" version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -3736,7 +3730,7 @@ dependencies = [
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.13.0", "indexmap 2.13.1",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.1", "schemars 1.2.1",
"serde_core", "serde_core",
@@ -4670,9 +4664,9 @@ dependencies = [
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"zerovec", "zerovec",
@@ -4760,9 +4754,9 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.1",
"serde_core", "serde_core",
"serde_spanned 1.1.0", "serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
@@ -4789,9 +4783,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_datetime" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -4802,7 +4796,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.1",
"toml_datetime 0.6.3", "toml_datetime 0.6.3",
"winnow 0.5.40", "winnow 0.5.40",
] ]
@@ -4813,7 +4807,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.1",
"serde", "serde",
"serde_spanned 0.6.9", "serde_spanned 0.6.9",
"toml_datetime 0.6.3", "toml_datetime 0.6.3",
@@ -4822,30 +4816,30 @@ dependencies = [
[[package]] [[package]]
name = "toml_edit" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.1",
"toml_datetime 1.1.0+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.0", "winnow 1.0.1",
] ]
[[package]] [[package]]
name = "toml_parser" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.0", "winnow 1.0.1",
] ]
[[package]] [[package]]
name = "toml_writer" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]] [[package]]
name = "tower" name = "tower"
@@ -5157,9 +5151,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.115" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5170,9 +5164,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.65" version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5180,9 +5174,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.115" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5190,9 +5184,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.115" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -5203,9 +5197,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.115" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -5227,7 +5221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap 2.13.0", "indexmap 2.13.1",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
] ]
@@ -5253,15 +5247,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.13.0", "indexmap 2.13.1",
"semver", "semver",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.92" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5968,9 +5962,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -6013,7 +6007,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck 0.5.0", "heck 0.5.0",
"indexmap 2.13.0", "indexmap 2.13.1",
"prettyplease", "prettyplease",
"syn 2.0.117", "syn 2.0.117",
"wasm-metadata", "wasm-metadata",
@@ -6044,7 +6038,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.11.0", "bitflags 2.11.0",
"indexmap 2.13.0", "indexmap 2.13.1",
"log", "log",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -6063,7 +6057,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"id-arena", "id-arena",
"indexmap 2.13.0", "indexmap 2.13.1",
"log", "log",
"semver", "semver",
"serde", "serde",
@@ -6075,9 +6069,9 @@ dependencies = [
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.2" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "wry" name = "wry"
@@ -6156,9 +6150,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@@ -6167,9 +6161,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke-derive" name = "yoke-derive"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -6199,18 +6193,18 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [ dependencies = [
"zerofrom-derive", "zerofrom-derive",
] ]
[[package]] [[package]]
name = "zerofrom-derive" name = "zerofrom-derive"
version = "0.1.6" version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -6226,9 +6220,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"yoke", "yoke",
@@ -6237,9 +6231,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec" name = "zerovec"
version = "0.11.5" version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [ dependencies = [
"yoke", "yoke",
"zerofrom", "zerofrom",
@@ -6248,9 +6242,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec-derive" name = "zerovec-derive"
version = "0.11.2" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -6265,7 +6259,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"crc32fast", "crc32fast",
"indexmap 2.13.0", "indexmap 2.13.1",
"memchr", "memchr",
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.6.0" version = "0.7.0"
edition = "2021" edition = "2021"
[lib] [lib]
+85 -1
View File
@@ -61,10 +61,14 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path);
} }
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
// macOS: ~/Library/Application Support/Tachidesk/downloads
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
let base = std::env::var("XDG_DATA_HOME") let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/"))); .unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads") base.join("Tachidesk").join("downloads")
} }
#[tauri::command] #[tauri::command]
@@ -104,6 +108,82 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the resolved default downloads path for the current platform.
/// This mirrors resolve_downloads_path("") so the frontend can display it.
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
/// Returns true if the given path exists and is a directory.
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
/// Creates a directory and all missing parent directories.
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
/// Moves all content from `src` into `dst`, then removes `src`.
/// Emits `migrate_progress` events: `{ done, total, current }`.
/// Only deletes the source tree after every file is confirmed copied.
#[tauri::command]
async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(()); // nothing to migrate
}
// Count files first so the frontend can show accurate progress
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": 0u64, "total": total, "current": ""
}));
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done,
"total": total,
"current": rel.to_string_lossy()
}));
}
}
// Only remove source after all files are confirmed copied
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
/// Returns the OS/monitor DPI scale factor for the window's current monitor. /// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K, /// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied. /// 1.251.5 on Windows displays with OS-level scaling applied.
@@ -651,6 +731,10 @@ pub fn run() {
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server, spawn_server,
kill_server, kill_server,
get_platform_ui_scale, get_platform_ui_scale,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.6.0", "version": "0.7.0",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+165 -30
View File
@@ -5,6 +5,8 @@
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "./lib/client"; 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 { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord"; import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
@@ -76,15 +78,32 @@
let idle = $state(false); let idle = $state(false);
let devSplash = $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 platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
function applyZoom() { function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.5; const uiZoom = store.settings.uiZoom ?? 1.0;
const effective = platformScale * uiZoom; if (uiZoom === _appliedZoom) return;
const pct = effective * 100; _appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`; document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`); if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -130,7 +149,7 @@
}); });
$effect(() => { $effect(() => {
store.settings.uiZoom; platformScale; void store.settings.uiZoom;
applyZoom(); applyZoom();
}); });
@@ -185,30 +204,35 @@
function startProbe() { function startProbe() {
cancelProbe = false; cancelProbe = false;
failed = false; failed = false;
loginRequired = false;
let tries = 0; let tries = 0;
async function probe() { async function probe() {
if (cancelProbe) return; if (cancelProbe) return;
tries++; tries++;
try { const result = await probeServer();
const rawUrl = store.settings.serverUrl; if (cancelProbe) return;
const base = typeof rawUrl === "string" && rawUrl.trim()
? rawUrl.replace(/\/$/, "") if (result === "ok") {
: "http://127.0.0.1:4567"; serverProbeOk = true;
const s = store.settings; loginRequired = false;
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass return;
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` } }
: {};
const res = await fetch(`${base}/api/graphql`, { if (result === "auth_required") {
method: "POST", serverProbeOk = true;
headers: { "Content-Type": "application/json", ...auth }, loginRequired = true;
body: JSON.stringify({ query: "{ __typename }" }), return;
signal: AbortSignal.timeout(2000), }
});
if (res.ok && !cancelProbe) { serverProbeOk = true; return; } if (result === "unsupported_mode") {
} catch {} serverProbeOk = true;
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; } unsupportedMode = true;
if (!cancelProbe) setTimeout(probe, 750); return;
}
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
setTimeout(probe, 750);
} }
setTimeout(probe, 800); setTimeout(probe, 800);
@@ -275,23 +299,65 @@
} }
}); });
// When the reader closes, show idle presence.
$effect(() => { $effect(() => {
if (!store.activeChapter) { if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle(); if (store.settings.discordRpc) setIdle();
} }
}); });
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => {
window.addEventListener("keydown", handleZoomKey);
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() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
serverProbeOk = false; serverProbeOk = false;
loginRequired = false;
unsupportedMode = false;
startProbe(); startProbe();
} }
function handleBypass() { function handleBypass() {
cancelProbe = true; cancelProbe = true;
serverProbeOk = true; serverProbeOk = true;
loginRequired = false;
unsupportedMode = false;
appReady = true; appReady = true;
} }
</script> </script>
@@ -299,19 +365,65 @@
{#if devSplash} {#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} /> onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady} {:else if !appReady && !loginRequired && !unsupportedMode}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured} <SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
showCards={store.settings.splashCards ?? true} showCards={store.settings.splashCards ?? true}
onReady={() => appReady = true} onReady={() => { appReady = true; }}
onRetry={handleRetry} onRetry={handleRetry}
onBypass={handleBypass} /> 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} {:else}
<div class="root"> <div id="app-shell" class="root">
{#if idle && !store.activeChapter} {#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} /> onDismiss={() => { idle = false; resetIdle(); }} />
{/if} {/if}
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if} {#if !store.activeChapter}<TitleBar />{/if}
<div class="content"> <div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if} {#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div> </div>
@@ -330,4 +442,27 @@
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; 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> </style>
+6 -5
View File
@@ -50,19 +50,20 @@
</aside> </aside>
<style> <style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; } .root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; } .logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; } .logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); } .nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); } .nav::-webkit-scrollbar { display: none; }
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); } .tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); } .tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); } .bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); } .settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } .settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+36 -1
View File
@@ -4,7 +4,9 @@
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
const win = getCurrentWindow(); const win = getCurrentWindow();
const isMac = platform() === "macos"; const os = platform();
const isMac = os === "macos";
const isWindows = os === "windows";
let isFullscreen = $state(false); let isFullscreen = $state(false);
@@ -42,9 +44,42 @@
</div> </div>
{/if} {/if}
</div> </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">
<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} {/if}
<style> <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 { .bar {
display: flex; display: flex;
align-items: center; align-items: center;
+91 -38
View File
@@ -17,8 +17,8 @@
}); });
const icons: Record<Toast["kind"], string> = { const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14", download: "M12 3v13M7 11l5 5 5-5M5 21h14",
}; };
@@ -27,10 +27,15 @@
{#if store.toasts.length} {#if store.toasts.length}
<div class="toaster" aria-live="polite"> <div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)} {#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert"> <button
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div>
<span class="icon"> <span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" <svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} /> <path d={icons[t.kind]} />
</svg> </svg>
</span> </span>
@@ -38,54 +43,102 @@
<p class="title">{t.title}</p> <p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if} {#if t.body}<p class="sub">{t.body}</p>{/if}
</div> </div>
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button> </button>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
<style> <style>
.toaster { .toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5); position: fixed;
z-index: 9999; display: flex; flex-direction: column; bottom: var(--sp-5);
gap: var(--sp-2); pointer-events: none; max-width: 320px; right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
} }
.toast { .toast {
display: flex; align-items: flex-start; gap: var(--sp-2); display: flex;
padding: var(--sp-2) var(--sp-3); align-items: center;
border-radius: var(--radius-lg); gap: var(--sp-2);
border: 1px solid var(--border-base); padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised); background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08); border: 1px solid var(--border-dim);
pointer-events: all; min-width: 220px; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both; pointer-events: all;
min-width: 200px;
overflow: hidden;
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;
} }
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); } .toast:hover { opacity: 0.85; transform: translateX(-2px); }
.toast:active { transform: translateX(0) scale(0.98); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 1; transform: translateX(0) scale(1); }
} }
.toast-success { border-color: var(--accent-dim); }
.accent-bar {
width: 3px;
align-self: stretch;
flex-shrink: 0;
border-radius: 0 2px 2px 0;
margin-right: 2px;
}
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); } .toast-success .icon { color: var(--accent-fg); }
.toast-error { border-color: var(--color-error); }
.toast-error .icon { color: var(--color-error); } .toast-error .icon { color: var(--color-error); }
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); } .toast-info .icon { color: var(--text-muted); }
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); } .toast-download .icon { color: var(--accent-fg); }
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; } .body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub { .sub {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); font-family: var(--font-ui);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.close {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
</style> </style>
+3 -2
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; 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 { 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 { 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 { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
@@ -69,7 +69,8 @@
function rotatedSources(): Source[] { function rotatedSources(): Source[] {
const lang = store.settings.preferredExtensionLang || "en"; 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 []; if (!srcs.length) return [];
const off = store.discoverSrcOffset % srcs.length; const off = store.discoverSrcOffset % srcs.length;
return [...srcs.slice(off), ...srcs.slice(0, off)]; return [...srcs.slice(off), ...srcs.slice(0, off)];
+1 -1
View File
@@ -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 { 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; } .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; } .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 { 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:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.4; } .icon-btn:disabled { opacity: 0.4; }
+3 -3
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte"; import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -47,9 +47,9 @@
let abortCtrl: AbortController | null = null; let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => { const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
const libIds = new Set(libMatches.map((m) => m.id)); const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]); return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
}); });
const visibleItems = $derived(filtered.slice(0, visibleCount)); const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length); const hasMoreVisible = $derived(visibleCount < filtered.length);
+88 -12
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; 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 { 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 { 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 { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; 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 type { Manga, Category, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -73,6 +73,12 @@
const tabStatus = $derived( const tabStatus = $derived(
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter 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) { function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[store.libraryFilter]; const prev = store.settings.libraryTabSort[store.libraryFilter];
@@ -96,7 +102,32 @@
[store.libraryFilter]: status, [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([]); 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 // 5. Sort
const recentlyReadMap = new Map<number, number>(); const recentlyReadMap = new Map<number, number>();
if (mode === "recentlyRead") { if (mode === "recentlyRead") {
@@ -712,7 +750,11 @@
</button> </button>
{#if sortPanelOpen} {#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu"> <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} {#each ALL_SORT_MODES as m}
<button <button
class="panel-item" class="panel-item"
@@ -750,22 +792,47 @@
<div class="filter-panel-wrap"> <div class="filter-panel-wrap">
<button <button
class="icon-btn" class="icon-btn"
class:icon-btn-active={tabStatus !== "ALL"} class:icon-btn-active={hasActiveFilters}
title="Filter by status" title="Filter"
onclick={openFilterPanel} onclick={openFilterPanel}
> >
<Funnel size={15} weight={tabStatus !== "ALL" ? "fill" : "bold"} /> <Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
</button> </button>
{#if filterPanelOpen} {#if filterPanelOpen}
<div class="dropdown-panel filter-panel" role="menu"> <div class="dropdown-panel filter-panel" role="menu">
<p class="panel-label">Filter by status</p> <div class="panel-header">
{#each ALL_STATUS_FILTERS as s} <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 <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} class:panel-item-active={tabStatus === s}
role="menuitem" 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]} {STATUS_LABELS[s]}
</button> </button>
{/each} {/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 { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); } .panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; } .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; } .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 ──────────────────────────────────────────────────── */ /* ── 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; } .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; }
+78 -4
View File
@@ -3,6 +3,7 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; 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"; import type { Manga, Source, Chapter } from "../../lib/types";
interface Props { interface Props {
@@ -37,6 +38,46 @@
let sources: Source[] = $state([]); let sources: Source[] = $state([]);
let loadingSources = $state(true); let loadingSources = $state(true);
let selectedSource: Source | null = $state(null); 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 query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]); let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false); let searching = $state(false);
@@ -52,7 +93,14 @@
$effect(() => { $effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) 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) .catch(console.error)
.finally(() => { loadingSources = false; }); .finally(() => { loadingSources = false; });
@@ -178,7 +226,6 @@
<!-- Step 1: Pick source --> <!-- Step 1: Pick source -->
{#if step === "source"} {#if step === "source"}
<div class="source-list">
{#if loadingSources} {#if loadingSources}
<div class="centered"> <div class="centered">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
@@ -186,7 +233,22 @@
{:else if sources.length === 0} {:else if sources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div> <div class="centered"><span class="hint">No other sources installed.</span></div>
{:else} {:else}
{#each sources as src} {#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>
{/if}
<div class="source-list">
{#each visibleSources as src}
<button <button
class="source-row" class="source-row"
class:source-row-active={selectedSource?.id === src.id} class:source-row-active={selectedSource?.id === src.id}
@@ -200,8 +262,8 @@
<ArrowRight size={13} weight="light" class="source-arrow" /> <ArrowRight size={13} weight="light" class="source-arrow" />
</button> </button>
{/each} {/each}
{/if}
</div> </div>
{/if}
<!-- Step 2: Search & pick match --> <!-- Step 2: Search & pick match -->
{:else if step === "search"} {:else if step === "search"}
@@ -400,6 +462,18 @@
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); } :global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
.source-row:hover :global(.source-arrow) { opacity: 1; } .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 */
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); } .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; } .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; }
File diff suppressed because it is too large Load Diff
+293 -146
View File
@@ -1,15 +1,19 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; 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 { 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 { 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 { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; 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 type { Manga, Chapter, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte"; import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.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 CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000; const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -24,7 +28,7 @@
let loadingChapters: boolean = $state(true); let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set()); let enqueueing: Set<number> = $state(new Set());
let dlOpen: boolean = $state(false); let dlOpen: boolean = $state(false);
let detailsOpen: boolean = $state(false); let manageOpen: boolean = $state(false);
let togglingLibrary: boolean = $state(false); let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1); let chapterPage: number = $state(1);
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null); let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
@@ -44,64 +48,56 @@
let rangeTo: string = $state(""); let rangeTo: string = $state("");
let showRange: boolean = $state(false); let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false); let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state(); let autoOpen: boolean = $state(false);
let folderPickerRef: HTMLDivElement | undefined = $state(); let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
// Series link state
let linkPickerOpen: boolean = $state(false); let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state(""); let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]); let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false); let loadingLinkList: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
// Tracking modal let sortMenuOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false); let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null; let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null; let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null; let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
function formatDate(ts: string | null | undefined): string { function focusOnMount(node: HTMLElement) { node.focus(); }
if (!ts) return "";
const n = Number(ts); const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
const d = new Date(n > 1e10 ? n : n * 1000); if (!store.activeManga) return {};
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
});
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
} }
function applyChapters(nodes: Chapter[]) { const hasSelection = $derived(selectedIds.size > 0);
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
const sortDir = $derived(store.settings.chapterSortDir); const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source"); const sortMode = $derived(store.settings.chapterSortMode ?? "source");
let sortMenuOpen = $state(false);
const sortedChapters = $derived.by(() => { const sortedChapters = $derived.by(() => {
const base = [...chapters]; const base = [...chapters];
if (sortMode === "chapterNumber") { if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.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 if (sortMode === "uploadDate") { else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
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; return sortDir === "desc" ? base.reverse() : base;
}); });
/** const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
* 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 totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * 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 readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length); const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0); const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length); 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((() => { const continueChapter = $derived((() => {
if (!chapters.length) return null; if (!chapters.length) return null;
@@ -114,9 +110,71 @@
return { chapter: asc[0], type: "reread" as const }; return { chapter: asc[0], type: "reread" as const };
})()); })());
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null); const jumpChapter = $derived.by(() => {
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0)); const q = jumpInput.trim().toLowerCase();
const hasFolders = $derived(assignedFolders.length > 0); 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) { function loadCategories(mangaId: number) {
catsLoading = true; catsLoading = true;
@@ -131,7 +189,6 @@
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
// Sync local mangaCategories state after the mutation
if (chaps.length) { if (chaps.length) {
const allRead = chaps.every(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); 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() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
togglingLibrary = true; togglingLibrary = true;
@@ -252,6 +321,14 @@
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); 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 (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) { async function markBulk(ids: number[], isRead: boolean) {
@@ -260,6 +337,40 @@
const idSet = new Set(ids); const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c); 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 (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); 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) { function enqueueNext(n: number) {
if (!continueChapter) return; if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter); const idx = sortedChapters.indexOf(continueChapter.chapter);
@@ -360,29 +459,21 @@
addTo: inCat ? [] : [cat.id], addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [], removeFrom: inCat ? [cat.id] : [],
}); });
mangaCategories = inCat mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
const ahead = getPref("downloadAhead");
// ── Series link ──────────────────────────────────────────────────────────── if (ahead > 0) {
const idx = list.indexOf(ch);
const linkedIds = $derived( if (idx >= 0) {
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : [] const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
); if (toQueue.length) enqueueMultiple(toQueue);
}
const linkPickerResults = $derived.by(() => { }
const id = store.activeManga?.id; openReader(ch, list);
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];
});
async function openLinkPicker() { async function openLinkPicker() {
linkPickerOpen = true; linkSearch = ""; linkPickerOpen = true; linkSearch = "";
@@ -401,23 +492,22 @@
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id); if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
else linkManga(store.activeManga.id, other.id); else linkManga(store.activeManga.id, other.id);
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
{#if store.activeManga} {#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}> <div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
<div class="sidebar"> <div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}> <button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back <ArrowLeft size={13} weight="light" /> Back
</button> </button>
<!-- Zone 1: Cover -->
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" /> <img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
</div> </div>
<!-- Zone 2: Meta -->
{#if loadingManga} {#if loadingManga}
<div class="meta-skeleton"> <div class="meta-skeleton">
<div class="skeleton sk-line" style="width:90%;height:14px"></div> <div class="skeleton sk-line" style="width:90%;height:14px"></div>
@@ -450,10 +540,9 @@
</div> </div>
{/if} {/if}
<!-- Zone 3: Primary CTA + library action -->
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#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" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` ? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
@@ -473,7 +562,6 @@
</div> </div>
</div> </div>
<!-- Zone 4: Progress -->
{#if totalCount > 0} {#if totalCount > 0}
<div class="progress-section"> <div class="progress-section">
<div class="progress-header"> <div class="progress-header">
@@ -484,38 +572,37 @@
</div> </div>
{/if} {/if}
<!-- Zone 5: Details accordion (source info + all secondary actions) --> {#if !loadingManga && manga}
{#if !loadingManga && manga?.source}
<div class="details-section"> <div class="details-section">
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}> <button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Details</span> <span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" /> <CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button> </button>
{#if detailsOpen} {#if manageOpen}
<div class="details-body"> <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"> <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}> <button class="detail-action-btn" onclick={() => migrateOpen = true}>
<ArrowsClockwise size={12} weight="light" /> Switch Source <ArrowsClockwise size={12} weight="light" /> Switch Source
</button> </button>
<button <button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
class="detail-action-btn"
class:detail-action-active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} /> <LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""} Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button> </button>
<button <button class="detail-action-btn" onclick={() => trackingOpen = true}>
class="detail-action-btn"
onclick={() => trackingOpen = true}
>
<ChartLineUp size={12} weight="light" /> Tracking <ChartLineUp size={12} weight="light" /> Tracking
</button> </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} {#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}> <button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`} <Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
@@ -528,10 +615,17 @@
{/if} {/if}
</div> </div>
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
<div class="list-wrap"> <div class="list-wrap">
<div class="list-header"> <div class="list-header">
<div class="list-header-left"> <div class="list-header-left">
{#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"> <div class="sort-wrap">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}> <button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if} {#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
@@ -553,17 +647,33 @@
</div> </div>
{/if} {/if}
</div> </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"}> <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} {#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button> </button>
{/if}
</div> </div>
<div class="list-header-right"> <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>
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}> <button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
<!-- Category picker -->
<div class="fp-wrap" bind:this={folderPickerRef}> <div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}> <button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} /> <FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
@@ -584,12 +694,10 @@
<div class="fp-div"></div> <div class="fp-div"></div>
{#if folderCreating} {#if folderCreating}
<div class="fp-create"> <div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} <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 = ""; } }} 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-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}> <button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
<X size={12} weight="light" />
</button>
</div> </div>
{:else} {:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button> <button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
@@ -598,14 +706,18 @@
{/if} {/if}
</div> </div>
<!-- Download dropdown -->
{#if chapters.length > 0} {#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}> <div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}> <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="light" /> <Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
</button> </button>
{#if dlOpen} {#if dlOpen}
<div class="dl-dropdown"> <div class="dl-dropdown">
{#if downloadedCount > 0}
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
<div class="dl-divider"></div>
{/if}
{#if continueChapter} {#if continueChapter}
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)} {@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
{#if contIdx >= 0} {#if contIdx >= 0}
@@ -673,11 +785,13 @@
{:else if viewMode === "grid"} {:else if viewMode === "grid"}
{#each sortedChapters as ch, i} {#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} {@const isGridSelected = selectedIds.has(ch.id)}
onclick={() => openReader(ch, chaptersAsc)} <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 }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <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 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} {#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button> </button>
@@ -685,10 +799,14 @@
{:else} {:else}
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} {@const isSelected = selectedIds.has(ch.id)}
onclick={() => openReader(ch, chaptersAsc)} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
onkeydown={(e) => e.key === "Enter" && openReader(ch, chaptersAsc)} 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 }; }}> 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"> <div class="ch-left">
<span class="ch-name">{ch.name}</span> <span class="ch-name">{ch.name}</span>
<div class="ch-meta"> <div class="ch-meta">
@@ -700,11 +818,12 @@
<div class="ch-right"> <div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if} {#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded} {#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)} {:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" /> <CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else} {: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} {/if}
</div> </div>
</div> </div>
@@ -736,20 +855,25 @@
{/if} {/if}
{#if trackingOpen && store.activeManga} {#if trackingOpen && store.activeManga}
<TrackingPanel <TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
mangaId={store.activeManga.id} {/if}
mangaTitle={store.activeManga.title}
onClose={() => trackingOpen = false} {#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}
{#if linkPickerOpen} {#if linkPickerOpen}
<div <div class="link-backdrop" role="presentation"
class="link-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }} 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-modal">
<div class="link-header"> <div class="link-header">
<span class="link-title">Link as same series</span> <span class="link-title">Link as same series</span>
@@ -787,16 +911,13 @@
<style> <style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .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); } .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 { 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); } .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-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; } .cover { width: 100%; height: 100%; object-fit: cover; }
/* Zone 2: Meta */
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); } .meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); } .sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); } .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: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 { 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); } .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; } .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); } .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 { 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; } .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 { 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); } .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-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; } .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); } .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-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; } .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-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 { 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-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); } .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-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 { 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); } .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: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; } .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-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-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; } .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-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); } .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-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 { 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); } .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.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .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-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-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); } .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 { 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); } .fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Download dropdown ───────────────────────────────────────────────────── */
.dl-wrap { position: relative; } .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-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; } .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 { 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; } .dl-range-go:disabled { opacity: 0.3; cursor: default; }
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); } .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; } .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); } .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-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); } .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-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-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); } .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-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); } .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 fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
<script module> .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; }
function focusOnMount(node: HTMLElement) { node.focus(); } .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; }
</script> @keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
</style>
+56 -47
View File
@@ -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); } .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; } .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); } .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 { 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-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */ /* ── 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-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); } .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-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } .tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.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-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 ─────────────────────────────────────────────────────────── */
.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); } .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; } :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 { 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-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select { .filter-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px 28px 5px 10px; border-radius: var(--radius-md); padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); 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; 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-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 8px center; background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base), color var(--t-base); 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); } .filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */ /* ── 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 ─────────────────────────────────────────────────────────────── */ /* ── 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; } .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-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-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); } .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); } .retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */ /* ── Records list ───────────────────────────────────────────────────────── */
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); } .records-list { display: flex; flex-direction: column; gap: 2px; }
.record-card { .record-card {
display: flex; align-items: flex-start; gap: var(--sp-4); 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-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
background: var(--bg-raised); transition: background var(--t-fast), opacity var(--t-base);
transition: border-color var(--t-base), opacity var(--t-base);
} }
.record-card:hover { border-color: var(--border-strong); } .record-card:hover { background: var(--bg-raised); }
.record-busy { opacity: 0.5; pointer-events: none; } .record-busy { opacity: 0.4; pointer-events: none; }
/* Cover */ /* Cover */
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; } .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-empty { background: var(--bg-overlay); }
.record-cover-wrap:hover .record-cover { opacity: 0.8; } .record-cover-wrap:hover .record-cover { opacity: 0.75; }
.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-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 */ /* Body */
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .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-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-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-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 { 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 { 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:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); } .card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
@@ -577,24 +586,24 @@
/* Controls */ /* Controls */
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm); padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay); border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer; color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none; 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-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; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base); 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:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:disabled { opacity: 0.4; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .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; } .private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */ /* Progress */
.record-progress { display: flex; align-items: center; gap: var(--sp-3); } .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-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; } .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); } .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; } .record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
/* Chapter editor */ /* 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-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-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:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button, .chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .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-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: 4px; } .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-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-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 { 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); border-color: var(--border-strong); } .chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -34
View File
@@ -12,8 +12,6 @@
let { editingId = $bindable(null), onClose }: Props = $props(); let { editingId = $bindable(null), onClose }: Props = $props();
// ── Token group definitions ───────────────────────────────────────────────
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [ const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ {
label: "Backgrounds", label: "Backgrounds",
@@ -65,8 +63,6 @@
"color-info-bg": "Info background", "color-info-bg": "Info background",
}; };
// ── State ─────────────────────────────────────────────────────────────────
function loadInitial(): { name: string; tokens: ThemeTokens } { function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) { if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId); const existing = store.settings.customThemes.find(t => t.id === editingId);
@@ -81,13 +77,10 @@
let saveStatus: "idle" | "saved" = $state("idle"); let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null); let importError: string | null = $state(null);
// ── CSS vars helper ───────────────────────────────────────────────────────
function toCssVars(t: ThemeTokens): string { function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" "); return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
} }
// ── Actions ───────────────────────────────────────────────────────────────
function handleSave() { function handleSave() {
const name = themeName.trim() || "Untitled Theme"; const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`; const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
@@ -154,17 +147,15 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<!-- ── Main editor ────────────────────────────────────────────────────────────── --> <div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="te-backdrop" onclick={onClose} role="presentation">
<div <div
class="te-shell" class="te-shell"
role="dialog" role="dialog"
aria-label="Theme editor" aria-label="Theme editor"
tabindex="0"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- ── Header ──────────────────────────────────────────────────────── -->
<header class="te-header"> <header class="te-header">
<div class="te-header-left"> <div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor"> <button class="te-icon-btn" onclick={onClose} title="Close editor">
@@ -209,25 +200,17 @@
</div> </div>
</header> </header>
<!-- ── Body ───────────────────────────────────────────────────────── -->
<div class="te-body"> <div class="te-body">
<!-- Left: live preview -->
<aside class="te-preview-pane"> <aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div> <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)}> <div class="te-preview-ui" style={toCssVars(tokens)}>
<!-- Sidebar -->
<div class="prv-sidebar"> <div class="prv-sidebar">
{#each [true, false, false, false] as active} {#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div> <div class="prv-sb-dot" class:active></div>
{/each} {/each}
</div> </div>
<!-- Main -->
<div class="prv-main"> <div class="prv-main">
<div class="prv-titlebar"> <div class="prv-titlebar">
<div class="prv-win-dots"> <div class="prv-win-dots">
@@ -262,7 +245,6 @@
</div> </div>
</div> </div>
<!-- Swatch strip — scoped to draft tokens too -->
<div class="te-swatches" style={toCssVars(tokens)}> <div class="te-swatches" style={toCssVars(tokens)}>
{#each [ {#each [
["bg-base","bg-base"],["bg-surface","bg-surface"], ["bg-base","bg-base"],["bg-surface","bg-surface"],
@@ -279,7 +261,6 @@
</div> </div>
</aside> </aside>
<!-- Right: token editor -->
<div class="te-editor-pane"> <div class="te-editor-pane">
{#each TOKEN_GROUPS as group} {#each TOKEN_GROUPS as group}
<div class="te-group"> <div class="te-group">
@@ -287,7 +268,14 @@
<div class="te-token-list"> <div class="te-token-list">
{#each group.tokens as token} {#each group.tokens as token}
<div class="te-token-row"> <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-name">{TOKEN_LABELS[token]}</span>
<span class="te-token-key">{token}</span> <span class="te-token-key">{token}</span>
<input <input
@@ -318,20 +306,16 @@
</div> </div>
<style> <style>
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
.te-backdrop { .te-backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72); background: rgba(0, 0, 0, 0.72);
z-index: 200; z-index: 200;
/* FIX 2: center the modal instead of stretch */
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both; animation: teBackdropIn 0.14s ease both;
} }
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } } @keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Shell ────────────────────────────────────────────────────────────────── */
.te-shell { .te-shell {
/* FIX 2: constrained dimensions so it doesn't fill the screen */
width: calc(100% - 48px); width: calc(100% - 48px);
max-width: 1100px; max-width: 1100px;
height: calc(100% - 48px); height: calc(100% - 48px);
@@ -348,7 +332,6 @@
to { transform: translateY(0) scale(1); opacity: 1; } to { transform: translateY(0) scale(1); opacity: 1; }
} }
/* ── Header ───────────────────────────────────────────────────────────────── */
.te-header { .te-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px; gap: 12px; padding: 0 16px; height: 46px;
@@ -420,10 +403,8 @@
.te-save-btn:hover { filter: brightness(1.12); } .te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); } .te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; } .te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
/* ── Preview pane ─────────────────────────────────────────────────────────── */
.te-preview-pane { .te-preview-pane {
width: 260px; flex-shrink: 0; width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim);
@@ -439,7 +420,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* te-preview-ui receives draft CSS vars via inline style */
.te-preview-ui { .te-preview-ui {
flex: 1; min-height: 0; flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden; border-radius: 8px; overflow: hidden;
@@ -447,7 +427,6 @@
display: flex; background: var(--bg-void); display: flex; background: var(--bg-void);
} }
/* Sidebar strip */
.prv-sidebar { .prv-sidebar {
width: 34px; flex-shrink: 0; width: 34px; flex-shrink: 0;
background: var(--bg-surface); 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-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; } .prv-toast-lines { flex: 1; }
/* Swatch strip */
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; } .te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch { .te-swatch {
width: 22px; height: 22px; border-radius: 4px; width: 22px; height: 22px; border-radius: 4px;
@@ -520,7 +498,6 @@
flex-shrink: 0; cursor: default; flex-shrink: 0; cursor: default;
} }
/* ── Editor pane ──────────────────────────────────────────────────────────── */
.te-editor-pane { .te-editor-pane {
flex: 1; overflow-y: auto; flex: 1; overflow-y: auto;
padding: 16px 20px; padding: 16px 20px;
@@ -552,10 +529,23 @@
.te-token-row:hover { background: var(--bg-raised); } .te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch { .te-color-swatch {
width: 16px; height: 16px; border-radius: 4px; width: 36px; height: 18px; border-radius: 5px;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2); 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 { .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>
+14 -13
View File
@@ -393,19 +393,18 @@
{#if !loadingDetail} {#if !loadingDetail}
<div class="meta-table"> <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} <div class="meta-grid">
{#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} <div class="meta-col">
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if} <div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if} <div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
{#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} <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>
{#if !loadingChapters && firstUpload && lastUpload} </div>
<div class="meta-row"> <div class="meta-col">
<span class="meta-key">Published</span> <div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}</span> <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}
{#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> </div>
{/if} {/if}
</div> </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 { 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); } .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-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-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-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 { 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; } .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; } .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; }
+198
View File
@@ -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>
+70 -66
View File
@@ -19,8 +19,6 @@
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
// ── State ──────────────────────────────────────────────────────────────────
type TabId = "records" | number; type TabId = "records" | number;
let trackers: Tracker[] = $state([]); let trackers: Tracker[] = $state([]);
@@ -39,8 +37,6 @@
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() { async function load() {
loading = true; loading = true;
try { try {
@@ -61,7 +57,6 @@
$effect(() => { load(); }); $effect(() => { load(); });
// Auto-search with manga title when switching to a tracker tab
$effect(() => { $effect(() => {
const tab = activeTab; const tab = activeTab;
if (typeof tab !== "number") return; if (typeof tab !== "number") return;
@@ -71,14 +66,10 @@
doSearch(tab, mangaTitle); doSearch(tab, mangaTitle);
}); });
// ── Helpers ────────────────────────────────────────────────────────────────
function trackerFor(id: number) { return trackers.find(t => t.id === id); } function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); } function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
// ── Search ─────────────────────────────────────────────────────────────────
let searchTimer: ReturnType<typeof setTimeout>; let searchTimer: ReturnType<typeof setTimeout>;
function onSearchInput() { function onSearchInput() {
@@ -105,8 +96,6 @@
} }
} }
// ── Bind / Unbind ──────────────────────────────────────────────────────────
async function bind(result: TrackSearch) { async function bind(result: TrackSearch) {
if (typeof activeTab !== "number") return; if (typeof activeTab !== "number") return;
binding = true; binding = true;
@@ -137,8 +126,6 @@
} }
} }
// ── Update ─────────────────────────────────────────────────────────────────
async function updateStatus(record: TrackRecord, status: number) { async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id; updatingRecord = record.id;
try { try {
@@ -234,7 +221,6 @@
> >
<div class="modal" role="dialog" aria-label="Tracking"> <div class="modal" role="dialog" aria-label="Tracking">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<span class="modal-title">Tracking</span> <span class="modal-title">Tracking</span>
@@ -256,7 +242,6 @@
</div> </div>
{:else} {:else}
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
<div class="tabs"> <div class="tabs">
<button <button
class="tab" class="tab"
@@ -282,7 +267,6 @@
{/each} {/each}
</div> </div>
<!-- ── My List tab ───────────────────────────────────────────────────── -->
{#if activeTab === "records"} {#if activeTab === "records"}
<div class="tab-body"> <div class="tab-body">
{#if records.length === 0} {#if records.length === 0}
@@ -434,7 +418,6 @@
{/if} {/if}
</div> </div>
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
{:else} {:else}
{@const tracker = trackerFor(activeTab as number)} {@const tracker = trackerFor(activeTab as number)}
{@const boundRecord = recordFor(activeTab as number)} {@const boundRecord = recordFor(activeTab as number)}
@@ -517,8 +500,8 @@
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
width: min(580px, calc(100vw - 48px)); width: min(560px, calc(100vw - 48px));
max-height: min(680px, calc(100vh - 80px)); max-height: min(660px, calc(100vh - 80px));
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base); background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden; 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; 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; } .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-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-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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: 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 { 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); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* States */ /* 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; } .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 */
.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; } .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 {
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } display: flex; align-items: center; gap: var(--sp-2); position: relative;
.tab-active { color: var(--text-secondary); background: var(--bg-raised); } font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } padding: 10px 10px 9px; color: var(--text-faint);
.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; } background: none; border: none; border-bottom: 2px solid transparent;
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } 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 */ /* 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; } .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-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 { 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: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-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-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm); padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay); border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer; color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none; 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-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 8px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base); 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:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:focus { border-color: var(--accent); outline: none; } .record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
.record-select:disabled { opacity: 0.4; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 100px; } .record-select-score { max-width: 90px; }
.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 { 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.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); } .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-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; } .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 { 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.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); } .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); } .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.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; } .record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
/* Chapter editor */ /* 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-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-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-input:focus { border-color: var(--accent); }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); } .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: 4px; } .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-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-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 { 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); border-color: var(--border-strong); } .chapter-cancel-btn:hover { color: var(--text-muted); }
/* Search */ /* 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; } :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 { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); } .search-input::placeholder { color: var(--text-faint); }
@@ -618,17 +623,16 @@
/* Results */ /* 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 { 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:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: var(--accent-muted) !important; } .result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !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-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); } .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-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-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-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-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-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; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
+107
View File
@@ -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"; }
}
+20 -13
View File
@@ -1,4 +1,5 @@
import { store } from "../store/state.svelte"; import { store } from "../store/state.svelte";
import { fetchAuthenticated } from "./auth";
const DEFAULT_URL = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
@@ -7,21 +8,27 @@ function getServerUrl(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL; 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`; } function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string { export function thumbUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("http")) return path; 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> { interface GQLResponse<T> {
@@ -51,12 +58,12 @@ async function fetchWithRetry(
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try { try {
const res = await fetch(url, { ...init, signal }); const res = await fetchAuthenticated(url, init, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res; return res;
} catch (e: any) { } catch (e: any) {
if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted; const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError"); if (isAbort) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e; if (i === retries - 1) throw e;
@@ -73,7 +80,7 @@ export async function gql<T>(
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), { const res = await fetchWithRetry(gqlUrl(), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeader() }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}, signal); }, signal);
+3
View File
@@ -13,6 +13,7 @@ export interface Keybinds {
toggleFullscreen: string; toggleFullscreen: string;
openSettings: string; openSettings: string;
toggleBookmark: string; toggleBookmark: string;
toggleMarker: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
toggleFullscreen: "f", toggleFullscreen: "f",
openSettings: "o", openSettings: "o",
toggleBookmark: "m", toggleBookmark: "m",
toggleMarker: "n",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -43,6 +45,7 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
toggleFullscreen: "Toggle fullscreen", toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings", openSettings: "Open settings",
toggleBookmark: "Toggle bookmark", toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
}; };
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
+36 -2
View File
@@ -187,6 +187,23 @@ export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath { query GetDownloadsPath {
settings { settings {
downloadsPath downloadsPath
localSourcePath
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
} }
} }
`; `;
@@ -424,8 +441,8 @@ export const GET_SOURCES = `
`; `;
export const FETCH_SOURCE_MANGA = ` export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) { mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) { fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { mangas {
id id
title title
@@ -871,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
}
}
`;
+23
View File
@@ -81,6 +81,29 @@ export function shouldHideNsfw(
return isNsfwManga(manga, settings.nsfwFilteredTags); 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 ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
+117 -135
View File
@@ -27,35 +27,38 @@ export type LibraryStatusFilter =
| "CANCELLED" | "CANCELLED"
| "HIATUS" | "HIATUS"
| "UNKNOWN"; | "UNKNOWN";
export type LibraryContentFilter =
| "unread"
| "started"
| "downloaded"
| "bookmarked"
| "marked";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm"; 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 Theme = BuiltinTheme | string;
export interface ThemeTokens { export interface ThemeTokens {
/* Backgrounds */
"bg-void": string; "bg-void": string;
"bg-base": string; "bg-base": string;
"bg-surface": string; "bg-surface": string;
"bg-raised": string; "bg-raised": string;
"bg-overlay": string; "bg-overlay": string;
"bg-subtle": string; "bg-subtle": string;
/* Borders */
"border-dim": string; "border-dim": string;
"border-base": string; "border-base": string;
"border-strong": string; "border-strong": string;
"border-focus": string; "border-focus": string;
/* Text */
"text-primary": string; "text-primary": string;
"text-secondary": string; "text-secondary": string;
"text-muted": string; "text-muted": string;
"text-faint": string; "text-faint": string;
"text-disabled": string; "text-disabled": string;
/* Accent */
"accent": string; "accent": string;
"accent-dim": string; "accent-dim": string;
"accent-muted": string; "accent-muted": string;
"accent-fg": string; "accent-fg": string;
"accent-bright": string; "accent-bright": string;
/* Semantic */
"color-error": string; "color-error": string;
"color-error-bg": string; "color-error-bg": string;
"color-success": string; "color-success": string;
@@ -64,7 +67,7 @@ export interface ThemeTokens {
} }
export interface CustomTheme { export interface CustomTheme {
id: string; // "custom:abc123" id: string;
name: string; name: string;
tokens: ThemeTokens; tokens: ThemeTokens;
} }
@@ -97,14 +100,12 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"color-info-bg": "#121a1f", "color-info-bg": "#121a1f",
}; };
export interface HistoryEntry { export interface HistoryEntry {
mangaId: number; mangaId: number;
mangaTitle: string; mangaTitle: string;
thumbnailUrl: string; thumbnailUrl: string;
chapterId: number; chapterId: number;
chapterName: string; chapterName: string;
pageNumber: number;
readAt: number; readAt: number;
} }
@@ -116,21 +117,29 @@ export interface BookmarkEntry {
chapterName: string; chapterName: string;
pageNumber: number; pageNumber: number;
savedAt: number; savedAt: number;
/** Optional user label, e.g. "before the fight scene" */
label?: string; label?: string;
} }
/** export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
* ReadLogEntry append-only record of every chapter-completion event.
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI), export interface MarkerEntry {
* this log never overwrites existing entries. It is the source of truth id: string;
* for all reading stats. mangaId: number;
*/ mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
note: string;
color: MarkerColor;
createdAt: number;
updatedAt?: number;
}
export interface ReadLogEntry { export interface ReadLogEntry {
mangaId: number; mangaId: number;
chapterId: number; chapterId: number;
readAt: number; readAt: number;
/** Minutes spent on this chapter (estimated from page count or default). */
minutes: number; minutes: number;
} }
@@ -145,7 +154,7 @@ export interface ReadingStats {
lastStreakDate: string; 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 = { export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0, totalChaptersRead: 0,
@@ -172,16 +181,32 @@ export interface ActiveDownload {
progress: number; 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 { export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; 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; readerZoom: number;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
@@ -196,11 +221,6 @@ export interface Settings {
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; 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; uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
@@ -214,14 +234,16 @@ export interface Settings {
storageLimitGb: number | null; storageLimitGb: number | null;
markReadOnNext: boolean; markReadOnNext: boolean;
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme; theme: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
heroSlots: (number | null)[]; heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>; mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string; serverAuthUser: string;
serverAuthPass: string; serverAuthPass: string;
serverAuthEnabled: boolean; serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
socksProxyEnabled: boolean; socksProxyEnabled: boolean;
socksProxyHost: string; socksProxyHost: string;
socksProxyPort: string; socksProxyPort: string;
@@ -238,28 +260,20 @@ export interface Settings {
appLockPin: string; appLockPin: string;
customThemes: CustomTheme[]; customThemes: CustomTheme[];
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null; 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[]; nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[]; nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[]; nsfwBlockedSourceIds: string[];
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>; libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>; libraryTabStatus: Record<string, LibraryStatusFilter>;
// Legacy fields kept for migration reads only — never written after v3. libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
/** @deprecated use readerZoom */
maxPageWidth?: number; maxPageWidth?: number;
/** @deprecated use uiZoom */
uiScale?: number; uiScale?: number;
extraScanDirs: string[];
serverDownloadsPath: string;
serverLocalSourcePath: string;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: "longstrip",
readingDirection: "ltr", readingDirection: "ltr",
@@ -291,14 +305,16 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null, storageLimitGb: null,
markReadOnNext: true, markReadOnNext: true,
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true,
theme: "dark", theme: "dark",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
heroSlots: [null, null, null, null], heroSlots: [null, null, null, null],
mangaLinks: {}, mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "", serverAuthUser: "",
serverAuthPass: "", serverAuthPass: "",
serverAuthEnabled: false, serverAuthMode: "NONE",
socksProxyEnabled: false, socksProxyEnabled: false,
socksProxyHost: "", socksProxyHost: "",
socksProxyPort: "1080", socksProxyPort: "1080",
@@ -321,14 +337,14 @@ export const DEFAULT_SETTINGS: Settings = {
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
libraryTabSort: {}, libraryTabSort: {},
libraryTabStatus: {}, libraryTabStatus: {},
libraryTabFilters: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
}; };
// ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 3; 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)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom", "readerZoom",
@@ -379,10 +395,12 @@ function mergeSettings(saved: any): Settings {
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds }, keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null], heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {}, mangaLinks: saved?.settings?.mangaLinks ?? {},
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [], customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"], nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [], nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [], nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
@@ -400,32 +418,16 @@ function todayStr(): string {
const genId = () => Math.random().toString(36).slice(2, 10); const genId = () => Math.random().toString(36).slice(2, 10);
// ── Store ─────────────────────────────────────────────────────────────────────
class Store { class Store {
navPage: NavPage = $state(saved?.navPage ?? "home"); navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state("library"); libraryFilter: LibraryFilter = $state("library");
history: HistoryEntry[] = $state(saved?.history ?? []); 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 ?? []); 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 ?? []); bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []);
readingStats: ReadingStats = $state(mergeStats(saved)); readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(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); readerSessionId: number = $state(0);
genreFilter: string = $state(""); genreFilter: string = $state("");
searchPrefill: string = $state(""); searchPrefill: string = $state("");
activeManga: Manga | null = $state(null); activeManga: Manga | null = $state(null);
@@ -439,18 +441,8 @@ class Store {
toasts: Toast[] = $state([]); toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null); activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]); activeChapterList: Chapter[] = $state([]);
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
isFullscreen: boolean = $state(false); 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([]); 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>"
discoverCache: Map<string, Manga[]> = $state(new Map()); discoverCache: Map<string, Manga[]> = $state(new Map());
discoverLibraryIds: Set<number> = $state(new Set()); discoverLibraryIds: Set<number> = $state(new Set());
discoverSrcOffset: number = $state(0); discoverSrcOffset: number = $state(0);
@@ -463,58 +455,35 @@ class Store {
$effect(() => { persist({ history: this.history }); }); $effect(() => { persist({ history: this.history }); });
$effect(() => { persist({ readLog: this.readLog }); }); $effect(() => { persist({ readLog: this.readLog }); });
$effect(() => { persist({ bookmarks: this.bookmarks }); }); $effect(() => { persist({ bookmarks: this.bookmarks }); });
$effect(() => { persist({ markers: this.markers }); });
$effect(() => { persist({ readingStats: this.readingStats }); }); $effect(() => { persist({ readingStats: this.readingStats }); });
$effect(() => { persist({ settings: this.settings }); }); $effect(() => { persist({ settings: this.settings }); });
}); });
} }
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { 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; if (manga) this.activeManga = manga;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; this.pageUrls = [];
// Resume from the last saved position if history has one for this chapter. this.pageNumber = 1;
// history[n].pageNumber is kept up-to-date by the progress $effect in
// Reader.svelte as the user pages through, so this is always the last page
// they were on — not just the page they started from.
const saved = this.history.find(h => h.chapterId === chapter.id);
this.pageNumber = (saved && saved.pageNumber > 1) ? saved.pageNumber : 1;
} }
closeReader() { 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.activeChapter = null;
this.activeChapterList = []; this.activeChapterList = [];
this.pageUrls = []; this.pageUrls = [];
this.pageNumber = 1; 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) { 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) { if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; this.history[0] = { ...this.history[0], readAt: entry.readAt };
} else { } else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300); 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) { if (completed) {
const logEntry: ReadLogEntry = { const logEntry: ReadLogEntry = {
mangaId: entry.mangaId, mangaId: entry.mangaId,
@@ -525,12 +494,7 @@ class Store {
this.readLog = [...this.readLog, logEntry].slice(-5000); this.readLog = [...this.readLog, logEntry].slice(-5000);
} }
// ── 3. Recompute stats from the read log ────────────────────────────── const log = completed ? [...this.readLog] : this.readLog;
// 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 uniqueChapters = new Set(log.map(e => e.chapterId)); const uniqueChapters = new Set(log.map(e => e.chapterId));
const uniqueManga = new Set(log.map(e => e.mangaId)); const uniqueManga = new Set(log.map(e => e.mangaId));
@@ -559,15 +523,11 @@ 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) { addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label }; const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [ this.bookmarks = [
bookmark, bookmark,
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId), ...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200); ].slice(0, 200);
} }
@@ -575,26 +535,52 @@ class Store {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
} }
clearBookmarks() {
this.bookmarks = [];
}
getBookmark(chapterId: number): BookmarkEntry | undefined { getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId); return this.bookmarks.find(b => b.chapterId === chapterId);
} }
clearHistory() { this.history = []; this.readLog = []; } addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
/** const id = genId();
* Reset the resume position for a chapter back to page 1. const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() };
* Called when the user scrolls past a chapter boundary in longstrip the this.markers = [marker, ...this.markers].slice(0, 2000);
* chapter still appears in history (for the continue-reading UI), but return id;
* reopening it will start from page 1 instead of resuming mid-chapter. }
*/
resetChapterProgress(chapterId: number) { updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
this.history = this.history.map(h => this.markers = this.markers.map(m =>
h.chapterId === chapterId ? { ...h, pageNumber: 1 } : h 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) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId); this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
// Recompute stats after removal
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId)); const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
const uniqueManga = new Set(this.readLog.map(e => e.mangaId)); const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0); const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
@@ -609,11 +595,11 @@ class Store {
wipeAllData() { wipeAllData() {
this.history = []; this.history = [];
this.readLog = []; this.readLog = [];
this.markers = [];
this.readingStats = { ...DEFAULT_READING_STATS }; this.readingStats = { ...DEFAULT_READING_STATS };
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} }; this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
} }
linkManga(idA: number, idB: number) { linkManga(idA: number, idB: number) {
if (idA === idB) return; if (idA === idB) return;
const links = { ...this.settings.mangaLinks }; const links = { ...this.settings.mangaLinks };
@@ -660,7 +646,6 @@ class Store {
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; } updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; } resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
saveCustomTheme(theme: CustomTheme) { saveCustomTheme(theme: CustomTheme) {
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id); const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
const next = existing >= 0 const next = existing >= 0
@@ -675,13 +660,6 @@ class Store {
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme }; 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( async checkAndMarkCompleted(
mangaId: number, mangaId: number,
chaps: Chapter[], chaps: Chapter[],
@@ -696,7 +674,6 @@ class Store {
if (!completed) return; if (!completed) return;
if (allRead) { if (allRead) {
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error); 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) { if (UPDATE_MANGA) {
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error); await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
} }
@@ -720,13 +697,10 @@ class Store {
export const store = new 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 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 addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
export function resetChapterProgress(chapterId: number) { store.resetChapterProgress(chapterId); }
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); } export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
export function wipeAllData() { store.wipeAllData(); } export function wipeAllData() { store.wipeAllData(); }
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); } export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
@@ -753,7 +727,15 @@ export function resetKeybinds() { store
export function clearDiscoverCache() { store.clearDiscoverCache(); } export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); } export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); } export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); } 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 toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }