Compare commits

...

24 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
Youwes09 fd9d216325 Fix: Emergency Push + Bookmark Feature (WIP) 2026-03-30 00:02:21 -05:00
Youwes09 581eb2adb0 Fix: Home-Screen Argument for RPC & Total Time 2026-03-29 17:35:25 -05:00
37 changed files with 4384 additions and 2944 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)
[![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)
[![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>
@@ -131,7 +131,7 @@ pnpm tauri:dev
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)
---
+21 -20
View File
@@ -1,21 +1,24 @@
Major Revisions:
- Moku + Crossplatform Support (MacOS Remaining)
- Contemplate Anime Support, Add Novel Support (Consumet API)
- Enable Cloudflare Bypass (Suwayomi Config)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Adjustment in Settings for Theme Editor:
- Allow User to Edit/Create Themes
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- Integrate Download Directory Changes (Settings)
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
- Moku Discord RPC
- Write a better library for Discord RPC & Tauri
- Integrate Download Directory Changes (Settings)
Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library
- MacOS Full-Screen & UI Compatability (TitleBar)
- Fix Library Build not Updating
- Check Auth System (Only Supports Basic-Auth)
General/Misc Bugs:
- Fix Highlightable Elements
@@ -24,19 +27,17 @@ General/Misc Bugs:
- Fix Delete-All Crash (Deletes All but Cripples App)
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress:`
- Fix Reader Chapter Shifts (Glitched Sentinel)
- Still Shifts Down after reading ~8+ Chapters?
- Identify When Chapters are Unloaded, How to Preserve Structure
In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
flatpak build-bundle repo moku.flatpak dev.moku.app
Testing:
- Fix TitleBar not Appearing on Windows in Fullscreen (Locks in User)
- Integrate Download Directory Changes (Settings)
- Fix Source Allow in Content (Doesn't even work)
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: .
- type: file
path: packaging/frontend-dist.tar.gz
sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b
sha256: 43b7274bdab884aacbc3dad6f0f7c043d8e3d82b7bf7398e1df9f516ed553152
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
+1 -1
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }:
let
version = "0.6.0";
version = "0.7.0";
pkgs = import inputs.nixpkgs {
inherit system;
+114 -88
View File
@@ -1952,14 +1952,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hyper/hyper-1.8.1.crate",
"sha256": "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11",
"dest": "cargo/vendor/hyper-1.8.1"
"url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
"sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
"dest": "cargo/vendor/hyper-1.9.0"
},
{
"type": "inline",
"contents": "{\"package\": \"2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11\", \"files\": {}}",
"dest": "cargo/vendor/hyper-1.8.1",
"contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
"dest": "cargo/vendor/hyper-1.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2225,14 +2225,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.11.crate",
"sha256": "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb",
"dest": "cargo/vendor/iri-string-0.7.11"
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
"sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
"dest": "cargo/vendor/iri-string-0.7.12"
},
{
"type": "inline",
"contents": "{\"package\": \"d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.11",
"contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.12",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2355,14 +2355,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.92.crate",
"sha256": "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995",
"dest": "cargo/vendor/js-sys-0.3.92"
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.94.crate",
"sha256": "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9",
"dest": "cargo/vendor/js-sys-0.3.94"
},
{
"type": "inline",
"contents": "{\"package\": \"cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.92",
"contents": "{\"package\": \"2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.94",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3522,19 +3522,6 @@
"dest": "cargo/vendor/pin-project-lite-0.2.17",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/pin-utils/pin-utils-0.1.0.crate",
"sha256": "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184",
"dest": "cargo/vendor/pin-utils-0.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184\", \"files\": {}}",
"dest": "cargo/vendor/pin-utils-0.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -4198,6 +4185,19 @@
"dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rpcdiscord/rpcdiscord-0.2.6.crate",
"sha256": "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d",
"dest": "cargo/vendor/rpcdiscord-0.2.6"
},
{
"type": "inline",
"contents": "{\"package\": \"71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d\", \"files\": {}}",
"dest": "cargo/vendor/rpcdiscord-0.2.6",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -4604,14 +4604,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.0.crate",
"sha256": "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98",
"dest": "cargo/vendor/serde_spanned-1.1.0"
"url": "https://static.crates.io/crates/serde_spanned/serde_spanned-1.1.1.crate",
"sha256": "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26",
"dest": "cargo/vendor/serde_spanned-1.1.1"
},
{
"type": "inline",
"contents": "{\"package\": \"876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98\", \"files\": {}}",
"dest": "cargo/vendor/serde_spanned-1.1.0",
"contents": "{\"package\": \"6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26\", \"files\": {}}",
"dest": "cargo/vendor/serde_spanned-1.1.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5238,6 +5238,19 @@
"dest": "cargo/vendor/tauri-plugin-2.5.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-drpc/tauri-plugin-drpc-0.1.6.crate",
"sha256": "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6"
},
{
"type": "inline",
"contents": "{\"package\": \"7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -5644,14 +5657,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.0+spec-1.1.0.crate",
"sha256": "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f",
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0"
"url": "https://static.crates.io/crates/toml_datetime/toml_datetime-1.1.1+spec-1.1.0.crate",
"sha256": "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7",
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f\", \"files\": {}}",
"dest": "cargo/vendor/toml_datetime-1.1.0+spec-1.1.0",
"contents": "{\"package\": \"3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7\", \"files\": {}}",
"dest": "cargo/vendor/toml_datetime-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5683,40 +5696,40 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.8+spec-1.1.0.crate",
"sha256": "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c",
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0"
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.9+spec-1.1.0.crate",
"sha256": "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5",
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.8+spec-1.1.0",
"contents": "{\"package\": \"da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.9+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.0+spec-1.1.0.crate",
"sha256": "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011",
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0"
"url": "https://static.crates.io/crates/toml_parser/toml_parser-1.1.1+spec-1.1.0.crate",
"sha256": "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9",
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011\", \"files\": {}}",
"dest": "cargo/vendor/toml_parser-1.1.0+spec-1.1.0",
"contents": "{\"package\": \"39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9\", \"files\": {}}",
"dest": "cargo/vendor/toml_parser-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.0+spec-1.1.0.crate",
"sha256": "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed",
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0"
"url": "https://static.crates.io/crates/toml_writer/toml_writer-1.1.1+spec-1.1.0.crate",
"sha256": "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db",
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed\", \"files\": {}}",
"dest": "cargo/vendor/toml_writer-1.1.0+spec-1.1.0",
"contents": "{\"package\": \"756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db\", \"files\": {}}",
"dest": "cargo/vendor/toml_writer-1.1.1+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -6018,6 +6031,19 @@
"dest": "cargo/vendor/utf8_iter-1.0.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/uuid/uuid-0.8.2.crate",
"sha256": "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7",
"dest": "cargo/vendor/uuid-0.8.2"
},
{
"type": "inline",
"contents": "{\"package\": \"bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7\", \"files\": {}}",
"dest": "cargo/vendor/uuid-0.8.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -6164,66 +6190,66 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.115.crate",
"sha256": "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a",
"dest": "cargo/vendor/wasm-bindgen-0.2.115"
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.117.crate",
"sha256": "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0",
"dest": "cargo/vendor/wasm-bindgen-0.2.117"
},
{
"type": "inline",
"contents": "{\"package\": \"6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.115",
"contents": "{\"package\": \"0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.117",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.65.crate",
"sha256": "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65"
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.67.crate",
"sha256": "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67"
},
{
"type": "inline",
"contents": "{\"package\": \"2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.65",
"contents": "{\"package\": \"03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.67",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.115.crate",
"sha256": "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115"
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.117.crate",
"sha256": "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117"
},
{
"type": "inline",
"contents": "{\"package\": \"4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.115",
"contents": "{\"package\": \"7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.117",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.115.crate",
"sha256": "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115"
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.117.crate",
"sha256": "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117"
},
{
"type": "inline",
"contents": "{\"package\": \"921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.115",
"contents": "{\"package\": \"dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.117",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.115.crate",
"sha256": "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115"
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.117.crate",
"sha256": "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117"
},
{
"type": "inline",
"contents": "{\"package\": \"a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.115",
"contents": "{\"package\": \"39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.117",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -6281,14 +6307,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.92.crate",
"sha256": "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94",
"dest": "cargo/vendor/web-sys-0.3.92"
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.94.crate",
"sha256": "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a",
"dest": "cargo/vendor/web-sys-0.3.94"
},
{
"type": "inline",
"contents": "{\"package\": \"84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.92",
"contents": "{\"package\": \"cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.94",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7308,14 +7334,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/winnow/winnow-1.0.0.crate",
"sha256": "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8",
"dest": "cargo/vendor/winnow-1.0.0"
"url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate",
"sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5",
"dest": "cargo/vendor/winnow-1.0.1"
},
{
"type": "inline",
"contents": "{\"package\": \"a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.0",
"contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.1",
"dest-filename": ".cargo-checksum.json"
},
{
+91 -97
View File
@@ -1393,7 +1393,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"indexmap 2.13.1",
"slab",
"tokio",
"tokio-util",
@@ -1502,9 +1502,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -1516,7 +1516,6 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1600,12 +1599,13 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -1613,9 +1613,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -1626,9 +1626,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1640,15 +1640,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -1660,15 +1660,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -1725,9 +1725,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.13.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
@@ -1854,9 +1854,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.92"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"cfg-if",
"futures-util",
@@ -1905,7 +1905,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [
"cssparser 0.29.6",
"html5ever 0.29.1",
"indexmap 2.13.0",
"indexmap 2.13.1",
"selectors 0.24.0",
]
@@ -1941,9 +1941,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libloading"
@@ -1975,9 +1975,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]]
name = "moku"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"dirs 5.0.1",
"serde",
@@ -2123,9 +2123,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.17.1"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2773,12 +2773,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2798,7 +2792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"indexmap 2.13.1",
"quick-xml",
"serde",
"time",
@@ -2819,9 +2813,9 @@ dependencies = [
[[package]]
name = "potential_utf"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -2883,7 +2877,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.8+spec-1.1.0",
"toml_edit 0.25.10+spec-1.1.0",
]
[[package]]
@@ -3707,9 +3701,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -3736,7 +3730,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"indexmap 2.13.1",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
@@ -4670,9 +4664,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -4760,9 +4754,9 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.13.1",
"serde_core",
"serde_spanned 1.1.0",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -4789,9 +4783,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.1.0+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
@@ -4802,7 +4796,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.13.1",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
@@ -4813,7 +4807,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap 2.13.0",
"indexmap 2.13.1",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
@@ -4822,30 +4816,30 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.8+spec-1.1.0"
version = "0.25.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.1.0+spec-1.1.0",
"indexmap 2.13.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
name = "toml_parser"
version = "1.1.0+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
name = "toml_writer"
version = "1.1.0+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -5157,9 +5151,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.115"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
@@ -5170,9 +5164,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.65"
version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5180,9 +5174,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.115"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5190,9 +5184,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.115"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5203,9 +5197,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.115"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
@@ -5227,7 +5221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"indexmap 2.13.1",
"wasm-encoder",
"wasmparser",
]
@@ -5253,15 +5247,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap 2.13.1",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.92"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5968,9 +5962,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
@@ -6013,7 +6007,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.13.0",
"indexmap 2.13.1",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -6044,7 +6038,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"indexmap 2.13.0",
"indexmap 2.13.1",
"log",
"serde",
"serde_derive",
@@ -6063,7 +6057,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"indexmap 2.13.1",
"log",
"semver",
"serde",
@@ -6075,9 +6069,9 @@ dependencies = [
[[package]]
name = "writeable"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wry"
@@ -6156,9 +6150,9 @@ dependencies = [
[[package]]
name = "yoke"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6167,9 +6161,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -6199,18 +6193,18 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -6226,9 +6220,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -6237,9 +6231,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -6248,9 +6242,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
@@ -6265,7 +6259,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"indexmap 2.13.1",
"memchr",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moku"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
[lib]
+214 -15
View File
@@ -61,10 +61,14 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
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")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads")
base.join("Tachidesk").join("downloads")
}
#[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.
/// 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.
@@ -248,7 +328,28 @@ struct ServerInvocation {
working_dir: Option<PathBuf>,
}
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
///
/// Expected layout (Windows and Linux):
/// <bundle_dir>/jre/bin/java[.exe]
///
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
@@ -261,7 +362,10 @@ fn resolve_server_binary(
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
// 1. User-specified binary path
// ── 1. User-specified binary path ─────────────────────────────────────────
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
// Fallback: if the path doesn't exist after stripping UNC, log a warning
// and continue so the bundled detection still has a chance.
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
@@ -272,17 +376,58 @@ fn resolve_server_binary(
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
return Err(SpawnError::NotConfigured(
format!("Configured binary not found: {}", path.display()),
));
// Fallback: path was set but file is missing — warn and keep trying.
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
}
// 2. Bundled sidecar (Windows / Linux AppImage)
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
#[cfg(not(target_os = "macos"))]
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
stripped
};
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
#[cfg(not(target_os = "macos"))]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &candidates {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar));
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
match find_java_in_bundle(&bundle_dir, log) {
Some(java) => {
do_log(log, &format!("[resolve] java found: {:?}", java));
if jar.exists() {
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
do_log(log, "[resolve] java found but jar MISSING — falling through");
}
None => {
do_log(log, "[resolve] java NOT found in bundle — falling through");
}
}
}
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
// Fallback for older bundle layouts that ship a wrapper script instead of a
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
#[cfg(not(target_os = "macos"))]
{
// Named launcher scripts.
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &script_candidates {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
if p.exists() {
@@ -290,24 +435,64 @@ fn resolve_server_binary(
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(resource_dir),
working_dir: Some(resource_dir.clone()),
});
}
}
// Generic JRE at resource_dir root + any *.jar alongside it.
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| {
e.as_ref()
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
.unwrap_or(false)
})
.and_then(|e| e.ok())
.map(|e| e.path())
});
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
}
}
// 3. macOS app bundle — look in MacOS/ and Resources/
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir.parent()
let macos_dir = resource_dir
.parent()
.map(|p| p.join("MacOS"))
.unwrap_or_default();
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
// Tauri strips the target triple when installing externalBin sidecars into
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
// dev / flat layouts.
let candidates = [
"suwayomi-server",
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
// Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources.
for search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates {
let p = search_dir.join(name);
@@ -324,8 +509,18 @@ fn resolve_server_binary(
}
}
// ── 4. PATH fallback ──────────────────────────────────────────────────────
// Use `where` on Windows, `which` everywhere else.
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let found = std::process::Command::new("where")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which")
.arg(name)
.output()
@@ -536,6 +731,10 @@ pub fn run() {
.manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server,
kill_server,
get_platform_ui_scale,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.6.0",
"version": "0.7.0",
"identifier": "dev.moku.app",
"build": {
"frontendDist": "../dist",
+166 -45
View File
@@ -5,6 +5,8 @@
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "./lib/client";
import logoUrl from "./assets/moku-icon-splash.svg";
import { probeServer, loginBasic, authSession, logout } from "./lib/auth";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
@@ -76,22 +78,32 @@
let idle = $state(false);
let devSplash = $state(false);
// The OS/monitor DPI scale factor for the current display.
// Queried from Rust (window.scale_factor()) on mount and updated live
// whenever the window moves to a different monitor via the scaleChanged event.
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.251.5 = Windows scaled display.
let platformScale = $state(1.0);
let loginRequired = $state(false);
let loginUser = $state(store.settings.serverAuthUser ?? "");
let loginPass = $state("");
let loginError = $state<string | null>(null);
let loginBusy = $state(false);
let unsupportedMode = $state(false);
let platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
// Applied to document.documentElement so the entire UI scales correctly.
function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.5;
const effective = platformScale * uiZoom;
const pct = effective * 100;
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_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.setProperty("--ui-scale", String(effective));
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
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[] = [];
@@ -136,9 +148,8 @@
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
});
// Re-apply zoom whenever uiZoom setting or platformScale changes.
$effect(() => {
store.settings.uiZoom; platformScale;
void store.settings.uiZoom;
applyZoom();
});
@@ -193,30 +204,35 @@
function startProbe() {
cancelProbe = false;
failed = false;
loginRequired = false;
let tries = 0;
async function probe() {
if (cancelProbe) return;
tries++;
try {
const rawUrl = store.settings.serverUrl;
const base = typeof rawUrl === "string" && rawUrl.trim()
? rawUrl.replace(/\/$/, "")
: "http://127.0.0.1:4567";
const s = store.settings;
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
: {};
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
if (!cancelProbe) setTimeout(probe, 750);
const result = await probeServer();
if (cancelProbe) return;
if (result === "ok") {
serverProbeOk = true;
loginRequired = false;
return;
}
if (result === "auth_required") {
serverProbeOk = true;
loginRequired = true;
return;
}
if (result === "unsupported_mode") {
serverProbeOk = true;
unsupportedMode = true;
return;
}
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
setTimeout(probe, 750);
}
setTimeout(probe, 800);
@@ -226,8 +242,6 @@
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
// Fetch the real monitor scale factor from Rust (window.scale_factor()).
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom();
@@ -237,9 +251,6 @@
store.isFullscreen = await win.isFullscreen();
});
// Re-query the scale factor when the window moves to a different monitor.
// Tauri emits this event whenever the DPI changes (e.g. dragging window
// from a 1080p display to a 4K display).
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
@@ -288,24 +299,65 @@
}
});
// When the reader closes, show idle presence.
$effect(() => {
if (!store.activeChapter) {
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() {
failed = false;
notConfigured = false;
serverProbeOk = false;
loginRequired = false;
unsupportedMode = false;
startProbe();
}
function handleBypass() {
cancelProbe = true;
serverProbeOk = true;
loginRequired = false;
unsupportedMode = false;
appReady = true;
}
</script>
@@ -313,19 +365,65 @@
{#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady}
{:else if !appReady && !loginRequired && !unsupportedMode}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => appReady = true}
onReady={() => { appReady = true; }}
onRetry={handleRetry}
onBypass={handleBypass} />
{:else if unsupportedMode}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge auth-mode-badge--warn">{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
}</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
<p class="auth-body">
<strong>{
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
</p>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
</div>
</div>
{:else if loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<div class="auth-overlay">
<div class="auth-card">
<img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p>
<span class="auth-mode-badge">Basic Auth</span>
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
{#if loginError}
<p class="auth-error">{loginError}</p>
{/if}
<div class="auth-fields">
<input class="auth-input" type="text" placeholder="Username"
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
<input class="auth-input" type="password" placeholder="Password"
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
</div>
<button class="auth-btn" onclick={handleLogin}
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
{loginBusy ? "Signing in…" : "Sign in"}
</button>
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
</div>
</div>
{:else}
<div class="root">
<div id="app-shell" class="root">
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} />
{/if}
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
{#if !store.activeChapter}<TitleBar />{/if}
<div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
@@ -344,4 +442,27 @@
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
/* Auth overlay — floats above the SplashScreen */
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
.auth-input:disabled { opacity: 0.5; }
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
.auth-btn:disabled { opacity: 0.35; cursor: default; }
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
</style>
+6 -5
View File
@@ -50,19 +50,20 @@
</aside>
<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; }
.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; }
.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; 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:active { transform: scale(0.92); }
.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; }
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
.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 { 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; }
.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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { 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:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+38 -3
View File
@@ -4,7 +4,9 @@
import { platform } from "@tauri-apps/plugin-os";
const win = getCurrentWindow();
const isMac = platform() === "macos";
const os = platform();
const isMac = os === "macos";
const isWindows = os === "windows";
let isFullscreen = $state(false);
@@ -18,7 +20,7 @@
</script>
{#if !isFullscreen}
<div class="bar" data-tauri-drag-region>
<div class="bar" data-tauri-drag-region>
{#if isMac}<div class="mac-spacer"></div>{/if}
<span class="title" data-tauri-drag-region>Moku</span>
{#if !isMac}
@@ -41,10 +43,43 @@
</button>
</div>
{/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}
<style>
.fullscreen-controls {
position: fixed;
top: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
opacity: 0;
transition: opacity 0.2s ease;
-webkit-app-region: no-drag;
}
.fullscreen-controls:hover { opacity: 1; }
.bar {
display: flex;
align-items: center;
+91 -38
View File
@@ -17,8 +17,8 @@
});
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",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
success: "M20 6L9 17l-5-5",
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",
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
};
@@ -27,10 +27,15 @@
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#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">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} />
</svg>
</span>
@@ -38,54 +43,102 @@
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</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>
</div>
{/each}
</div>
{/if}
<style>
.toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
z-index: 9999; display: flex; flex-direction: column;
gap: var(--sp-2); pointer-events: none; max-width: 320px;
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
}
.toast {
display: flex; align-items: flex-start; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-base);
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
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);
pointer-events: all; min-width: 220px;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
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); }
}
.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-error { border-color: var(--color-error); }
.toast-error .icon { color: var(--color-error); }
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
.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; }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.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 {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
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;
}
.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>
+5 -4
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, isNsfwManga } from "../../lib/util";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte";
@@ -62,14 +62,15 @@
function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => {
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
if (!store.settings.showNsfw && isNsfwManga(m)) return false;
if (shouldHideNsfw(m, store.settings)) return false;
return true;
}));
}
function rotatedSources(): Source[] {
const lang = store.settings.preferredExtensionLang || "en";
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
const srcs = dedupeSources(eligible, lang);
if (!srcs.length) return [];
const off = store.discoverSrcOffset % srcs.length;
return [...srcs.slice(off), ...srcs.slice(0, off)];
@@ -188,7 +189,7 @@
if (ctrl.signal.aborted) return;
const local = dedup(
d.mangas.nodes.filter(m => store.settings.showNsfw || !isNsfwManga(m))
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
);
store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT));
+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-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-1); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.4; }
+3 -3
View File
@@ -4,7 +4,7 @@
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 { 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 type { Manga, Source, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -47,9 +47,9 @@
let abortCtrl: AbortController | null = null;
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));
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 hasMoreVisible = $derived(visibleCount < filtered.length);
+12 -4
View File
@@ -173,7 +173,11 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
openReader(chapter, all);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; }
}
@@ -188,8 +192,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, chapters);
}
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; }
}
@@ -199,8 +205,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, chapters);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
+90 -16
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown } from "phosphor-svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, isNsfwManga } from "../../lib/util";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
import type { Manga, Category, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -73,6 +73,12 @@
const tabStatus = $derived(
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
);
const tabFilters = $derived(
store.settings.libraryTabFilters?.[store.libraryFilter] ?? {} as Partial<Record<LibraryContentFilter, boolean>>
);
const hasActiveFilters = $derived(
tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)
);
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[store.libraryFilter];
@@ -96,7 +102,32 @@
[store.libraryFilter]: status,
},
});
filterPanelOpen = false;
}
function toggleTabFilter(filter: LibraryContentFilter) {
const current = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
updateSettings({
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[store.libraryFilter]: {
...current,
[filter]: !current[filter],
},
},
});
}
function clearAllFilters() {
updateSettings({
libraryTabStatus: {
...store.settings.libraryTabStatus,
[store.libraryFilter]: "ALL",
},
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[store.libraryFilter]: {},
},
});
}
let allManga: Manga[] = $state([]);
@@ -320,9 +351,7 @@
}
// 2. NSFW filter — always applied before text search or sort
if (!store.settings.showNsfw) {
items = items.filter(m => !isNsfwManga(m));
}
items = items.filter(m => !shouldHideNsfw(m, store.settings));
// 3. Text search
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
@@ -335,6 +364,13 @@
});
}
// 4b. Content filters (additive — each active filter further narrows the list)
const filters = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (filters.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
if (filters.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (filters.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
// 5. Sort
const recentlyReadMap = new Map<number, number>();
if (mode === "recentlyRead") {
@@ -714,7 +750,11 @@
</button>
{#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu">
<p class="panel-label">Sort by</p>
<div class="panel-header">
<span class="panel-heading">Sort</span>
</div>
<div class="panel-divider"></div>
<p class="panel-label">Order by</p>
{#each ALL_SORT_MODES as m}
<button
class="panel-item"
@@ -752,22 +792,47 @@
<div class="filter-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={tabStatus !== "ALL"}
title="Filter by status"
class:icon-btn-active={hasActiveFilters}
title="Filter"
onclick={openFilterPanel}
>
<Funnel size={15} weight={tabStatus !== "ALL" ? "fill" : "bold"} />
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterPanelOpen}
<div class="dropdown-panel filter-panel" role="menu">
<p class="panel-label">Filter by status</p>
{#each ALL_STATUS_FILTERS as s}
<div class="panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={clearAllFilters}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each ([["unread","Unread"],["started","Started"],["downloaded","Downloaded"],["bookmarked","Bookmarked"]] as const) as [f, label]}
<button
class="panel-item"
class="panel-item panel-item-check"
class:panel-item-active={tabFilters[f]}
role="menuitem"
onclick={() => toggleTabFilter(f)}
>
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
<div class="panel-divider"></div>
<p class="panel-label">Status</p>
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
<button
class="panel-item panel-item-check"
class:panel-item-active={tabStatus === s}
role="menuitem"
onclick={() => setTabStatus(s)}
onclick={() => setTabStatus(tabStatus === s ? "ALL" : s)}
>
<span class="panel-check" class:panel-check-on={tabStatus === s}>
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
</span>
{STATUS_LABELS[s]}
</button>
{/each}
@@ -936,8 +1001,17 @@
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
/* Panel header row — shared by sort + filter panels */
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.panel-clear-btn:hover { color: var(--color-error); }
/* Check items */
.panel-item-check { justify-content: flex-start; gap: var(--sp-2); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
.sort-caret { flex-shrink: 0; }
:global(.sort-caret) { flex-shrink: 0; }
/* ── Selection toolbar ──────────────────────────────────────────────────── */
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
+78 -4
View File
@@ -3,6 +3,7 @@
import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import { store } from "../../store/state.svelte";
import type { Manga, Source, Chapter } from "../../lib/types";
interface Props {
@@ -37,6 +38,46 @@
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
// Lang filter: "en" first, then alphabetical
let selectedLang: string = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
const en = langs.indexOf("en");
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
return langs;
});
const hasMultipleLangs = $derived(availableLangs.length > 1);
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const strip = langStripEl;
const chips = Array.from(strip.children) as HTMLElement[];
const scrollLeft = strip.scrollLeft;
const viewEnd = scrollLeft + strip.clientWidth;
if (dir === 1) {
// Find first chip that is cut off or fully outside the right edge, scroll it flush left
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) strip.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
// Find last chip that is cut off or fully outside the left edge, scroll it flush right
const prev = [...chips].reverse().find(c => c.offsetLeft < scrollLeft - 2);
if (prev) strip.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - strip.clientWidth, behavior: "smooth" });
}
}
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of sources) {
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
if (s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
let query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
@@ -52,7 +93,14 @@
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
.then((d) => {
const filtered = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id);
sources = filtered;
// Pre-select preferred lang if available and there are multiple
const prefLang = store?.settings?.preferredExtensionLang ?? "";
const langs = new Set(filtered.map(s => s.lang));
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
})
.catch(console.error)
.finally(() => { loadingSources = false; });
@@ -178,7 +226,6 @@
<!-- Step 1: Pick source -->
{#if step === "source"}
<div class="source-list">
{#if loadingSources}
<div class="centered">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
@@ -186,7 +233,22 @@
{:else if sources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div>
{: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
class="source-row"
class:source-row-active={selectedSource?.id === src.id}
@@ -200,8 +262,8 @@
<ArrowRight size={13} weight="light" class="source-arrow" />
</button>
{/each}
{/if}
</div>
{/if}
<!-- Step 2: Search & pick match -->
{:else if step === "search"}
@@ -400,6 +462,18 @@
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
.source-row:hover :global(.source-arrow) { opacity: 1; }
/* Lang filter bar */
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
.src-lang-chip:last-child { margin-right: var(--sp-1); }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.src-lang-chip-active:hover { color: var(--accent-fg); border-color: var(--accent); }
/* Search step */
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
File diff suppressed because it is too large Load Diff
+293 -146
View File
@@ -1,15 +1,19 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.svelte";
import AutomationPanel from "../shared/AutomationPanel.svelte";
import MarkersPanel from "../shared/MarkersPanel.svelte";
const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -24,7 +28,7 @@
let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set());
let dlOpen: boolean = $state(false);
let detailsOpen: boolean = $state(false);
let manageOpen: boolean = $state(false);
let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1);
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
@@ -44,64 +48,56 @@
let rangeTo: string = $state("");
let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
// Series link state
let autoOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false);
// Tracking modal
let trackingOpen: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
let sortMenuOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
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 focusOnMount(node: HTMLElement) { node.focus(); }
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
if (!store.activeManga) return {};
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[]) {
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
const hasSelection = $derived(selectedIds.size > 0);
const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
let sortMenuOpen = $state(false);
const sortedChapters = $derived.by(() => {
const base = [...chapters];
if (sortMode === "chapterNumber") {
base.sort((a, b) => a.chapterNumber - b.chapterNumber);
} else if (sortMode === "uploadDate") {
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
} else {
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
}
if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
return sortDir === "desc" ? base.reverse() : base;
});
/**
* Chapter list in canonical reading order (ch1 -> ch2 -> ch3).
* Always passed to openReader so the Reader's idx-based prev/next
* navigation is direction-independent of the user's display sort.
*/
const chaptersAsc = $derived(
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
);
const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const hasFolders = $derived(assignedFolders.length > 0);
const continueChapter = $derived((() => {
if (!chapters.length) return null;
@@ -114,9 +110,71 @@
return { chapter: asc[0], type: "reread" as const };
})());
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const hasFolders = $derived(assignedFolders.length > 0);
const jumpChapter = $derived.by(() => {
const q = jumpInput.trim().toLowerCase();
if (!q) return null;
const num = parseFloat(q);
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
});
const hasAnyAutomation = $derived(
getPref("autoDownload") ||
getPref("downloadAhead") > 0 ||
getPref("maxKeepChapters") > 0 ||
getPref("deleteOnRead") ||
getPref("pauseUpdates") ||
getPref("refreshInterval") !== "global" ||
!!getPref("preferredScanlator")
);
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function doJump() {
if (!jumpChapter) return;
const pageIdx = sortedChapters.indexOf(jumpChapter);
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
jumpOpen = false;
jumpInput = "";
}
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
e.stopPropagation();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedIds = next;
}
function clearSelection() { selectedIds = new Set(); }
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function applyChapters(nodes: Chapter[]) {
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
_prevChapterIds = new Set(nodes.map(c => c.id));
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
function loadCategories(mangaId: number) {
catsLoading = true;
@@ -131,7 +189,6 @@
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
// Sync local mangaCategories state after the mutation
if (chaps.length) {
const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed");
@@ -215,6 +272,18 @@
}
});
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
async function toggleLibrary() {
if (!manga) return;
togglingLibrary = true;
@@ -252,6 +321,14 @@
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
if (delayMs === 0) deleteDownloaded(chapterId);
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
}
}
}
async function markBulk(ids: number[], isRead: boolean) {
@@ -260,6 +337,40 @@
const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (ids.length) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
clearSelection();
}
async function downloadSelected() {
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
await enqueueMultiple(ids);
clearSelection();
}
async function markSelectedRead(isRead: boolean) {
await markBulk([...selectedIds], isRead);
clearSelection();
}
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
@@ -312,18 +423,6 @@
];
}
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
function enqueueNext(n: number) {
if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter);
@@ -360,29 +459,21 @@
addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [],
});
mangaCategories = inCat
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
// ── Series link ────────────────────────────────────────────────────────────
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
const ahead = getPref("downloadAhead");
if (ahead > 0) {
const idx = list.indexOf(ch);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
openReader(ch, list);
}
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
@@ -401,23 +492,22 @@
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
else linkManga(store.activeManga.id, other.id);
}
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
<div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<!-- Zone 1: Cover -->
<div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
</div>
<!-- Zone 2: Meta -->
{#if loadingManga}
<div class="meta-skeleton">
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
@@ -450,10 +540,9 @@
</div>
{/if}
<!-- Zone 3: Primary CTA + library action -->
<div class="cta-section">
{#if continueChapter}
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, chaptersAsc)}>
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}>
<Play size={12} weight="fill" />
{continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
@@ -473,7 +562,6 @@
</div>
</div>
<!-- Zone 4: Progress -->
{#if totalCount > 0}
<div class="progress-section">
<div class="progress-header">
@@ -484,38 +572,37 @@
</div>
{/if}
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
{#if !loadingManga && manga?.source}
{#if !loadingManga && manga}
<div class="details-section">
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
<span>Details</span>
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button>
{#if detailsOpen}
{#if manageOpen}
<div class="details-body">
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
<div class="detail-actions">
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
<Eye size={12} weight="light" /> Preview
</button>
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
<ArrowsClockwise size={12} weight="light" /> Switch Source
</button>
<button
class="detail-action-btn"
class:detail-action-active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button>
<button
class="detail-action-btn"
onclick={() => trackingOpen = true}
>
<button class="detail-action-btn" onclick={() => trackingOpen = true}>
<ChartLineUp size={12} weight="light" /> Tracking
</button>
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
</button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
</button>
{/if}
{#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
@@ -528,10 +615,17 @@
{/if}
</div>
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
<div class="list-wrap">
<div class="list-header">
<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">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
@@ -553,17 +647,33 @@
</div>
{/if}
</div>
<!-- View toggle: icon reflects current state -->
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button>
{/if}
</div>
<div class="list-header-right">
<div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" />
</button>
{#if jumpOpen}
<div class="jump-popover">
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
{#if jumpChapter}
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
{:else if jumpInput.trim()}
<p class="jump-none">No match</p>
{/if}
</div>
{/if}
</div>
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
<!-- Category picker -->
<div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
@@ -584,12 +694,10 @@
<div class="fp-div"></div>
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
<X size={12} weight="light" />
</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
</div>
{:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
@@ -598,14 +706,18 @@
{/if}
</div>
<!-- Download dropdown -->
{#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
<Download size={13} weight="light" />
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
</button>
{#if dlOpen}
<div class="dl-dropdown">
{#if downloadedCount > 0}
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
<div class="dl-divider"></div>
{/if}
{#if continueChapter}
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
{#if contIdx >= 0}
@@ -673,11 +785,13 @@
{:else if viewMode === "grid"}
{#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
onclick={() => openReader(ch, chaptersAsc)}
{@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button>
@@ -685,10 +799,14 @@
{:else}
{#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
onclick={() => openReader(ch, chaptersAsc)}
onkeydown={(e) => e.key === "Enter" && openReader(ch, chaptersAsc)}
{@const isSelected = selectedIds.has(ch.id)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
</button>
<div class="ch-left">
<span class="ch-name">{ch.name}</span>
<div class="ch-meta">
@@ -700,11 +818,12 @@
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
<span class="ch-dl-dot" title="Downloaded"></span>
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
@@ -736,20 +855,25 @@
{/if}
{#if trackingOpen && store.activeManga}
<TrackingPanel
mangaId={store.activeManga.id}
mangaTitle={store.activeManga.title}
onClose={() => trackingOpen = false}
/>
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
{/if}
{#if autoOpen && store.activeManga}
<AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
{/if}
{#if markersOpen && store.activeManga}
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
<div class="markers-panel-drawer">
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
{#if linkPickerOpen}
<div
class="link-backdrop"
role="presentation"
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
>
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal">
<div class="link-header">
<span class="link-title">Link as same series</span>
@@ -787,16 +911,13 @@
<style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
.back:hover { color: var(--text-secondary); }
/* Zone 1: Cover */
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.cover { width: 100%; height: 100%; object-fit: cover; }
/* Zone 2: Meta */
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
@@ -810,10 +931,8 @@
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* Description clamped — no expand in 240px sidebar */
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
/* Zone 3: CTA */
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.read-btn:hover { opacity: 0.88; }
@@ -825,7 +944,6 @@
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
/* Zone 4: Progress */
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -833,14 +951,10 @@
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
/* Zone 5: Details accordion */
.details-section { display: flex; flex-direction: column; gap: 2px; }
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
.details-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
@@ -850,7 +964,6 @@
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
/* ── Series link modal ───────────────────────────────────────────────────── */
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
@@ -874,7 +987,6 @@
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ── Chapter list ────────────────────────────────────────────────────────── */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
@@ -891,7 +1003,14 @@
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder picker ───────────────────────────────────────────────────────── */
.jump-wrap { position: relative; }
.jump-popover { position: absolute; top: calc(100% + 4px); right: 0; width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; display: flex; flex-direction: column; gap: var(--sp-1); }
.jump-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 5px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
.jump-input:focus { border-color: var(--border-focus); }
.jump-go { width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background var(--t-fast), border-color var(--t-fast); }
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
.fp-wrap { position: relative; }
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
@@ -910,7 +1029,6 @@
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Download dropdown ───────────────────────────────────────────────────── */
.dl-wrap { position: relative; }
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
@@ -935,7 +1053,6 @@
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
@@ -943,7 +1060,6 @@
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ── Chapter rows ────────────────────────────────────────────────────────── */
.ch-list { flex: 1; overflow-y: auto; }
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
@@ -969,10 +1085,41 @@
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); }
.sel-action-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
.dl-unified-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); transition: color var(--t-base); }
.dl-unified-btn:hover .dl-unified-count,
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
.ch-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-dot { opacity: 0; }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
</style>
+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); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; }
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
.tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
.tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
border-radius: 0; cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
}
.tracker-tab:hover { color: var(--text-muted); }
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); }
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; }
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 28px 5px 10px; border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-muted); outline: none; cursor: pointer;
color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base), color var(--t-base);
}
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); }
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* ── States ─────────────────────────────────────────────────────────────── */
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
@@ -535,29 +545,28 @@
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.records-list { display: flex; flex-direction: column; gap: 2px; }
.record-card {
display: flex; align-items: flex-start; gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-3) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
transition: border-color var(--t-base), opacity var(--t-base);
background: none;
transition: background var(--t-fast), opacity var(--t-base);
}
.record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.5; pointer-events: none; }
.record-card:hover { background: var(--bg-raised); }
.record-busy { opacity: 0.4; pointer-events: none; }
/* Cover */
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
.record-cover-empty { background: var(--bg-overlay); }
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
.record-cover-wrap:hover .record-cover { opacity: 0.75; }
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
/* Body */
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
@@ -566,9 +575,9 @@
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
@@ -577,24 +586,24 @@
/* Controls */
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
.record-select:disabled { opacity: 0.4; cursor: default; }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 90px; }
.record-select-score { max-width: 86px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
@@ -604,21 +613,21 @@
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
/* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
.chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
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();
// ── Token group definitions ───────────────────────────────────────────────
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{
label: "Backgrounds",
@@ -65,8 +63,6 @@
"color-info-bg": "Info background",
};
// ── State ─────────────────────────────────────────────────────────────────
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId);
@@ -81,13 +77,10 @@
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
// ── CSS vars helper ───────────────────────────────────────────────────────
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
// ── Actions ───────────────────────────────────────────────────────────────
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
@@ -154,17 +147,15 @@
<svelte:window onkeydown={onKey} />
<!-- ── Main editor ────────────────────────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="te-backdrop" onclick={onClose} role="presentation">
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div
class="te-shell"
role="dialog"
aria-label="Theme editor"
tabindex="0"
onclick={(e) => e.stopPropagation()}
>
<!-- ── Header ──────────────────────────────────────────────────────── -->
<header class="te-header">
<div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor">
@@ -209,25 +200,17 @@
</div>
</header>
<!-- ── Body ───────────────────────────────────────────────────────── -->
<div class="te-body">
<!-- Left: live preview -->
<aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div>
<!--
FIX 1: toCssVars scoped only to this element, so only the
preview UI sees the draft tokens — not the editor shell.
-->
<div class="te-preview-ui" style={toCssVars(tokens)}>
<!-- Sidebar -->
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<!-- Main -->
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots">
@@ -262,7 +245,6 @@
</div>
</div>
<!-- Swatch strip — scoped to draft tokens too -->
<div class="te-swatches" style={toCssVars(tokens)}>
{#each [
["bg-base","bg-base"],["bg-surface","bg-surface"],
@@ -279,7 +261,6 @@
</div>
</aside>
<!-- Right: token editor -->
<div class="te-editor-pane">
{#each TOKEN_GROUPS as group}
<div class="te-group">
@@ -287,7 +268,14 @@
<div class="te-token-list">
{#each group.tokens as token}
<div class="te-token-row">
<span class="te-color-swatch" style="background: {tokens[token]}"></span>
<label class="te-color-swatch" style="background: {tokens[token]}" title="Pick colour">
<input
type="color"
class="te-color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0,7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
<span class="te-token-key">{token}</span>
<input
@@ -318,20 +306,16 @@
</div>
<style>
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
.te-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: 200;
/* FIX 2: center the modal instead of stretch */
display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both;
}
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Shell ────────────────────────────────────────────────────────────────── */
.te-shell {
/* FIX 2: constrained dimensions so it doesn't fill the screen */
width: calc(100% - 48px);
max-width: 1100px;
height: calc(100% - 48px);
@@ -348,7 +332,6 @@
to { transform: translateY(0) scale(1); opacity: 1; }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.te-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px;
@@ -420,10 +403,8 @@
.te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
/* ── Preview pane ─────────────────────────────────────────────────────────── */
.te-preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
@@ -439,7 +420,6 @@
flex-shrink: 0;
}
/* te-preview-ui receives draft CSS vars via inline style */
.te-preview-ui {
flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden;
@@ -447,7 +427,6 @@
display: flex; background: var(--bg-void);
}
/* Sidebar strip */
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
@@ -512,7 +491,6 @@
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
/* Swatch strip */
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch {
width: 22px; height: 22px; border-radius: 4px;
@@ -520,7 +498,6 @@
flex-shrink: 0; cursor: default;
}
/* ── Editor pane ──────────────────────────────────────────────────────────── */
.te-editor-pane {
flex: 1; overflow-y: auto;
padding: 16px 20px;
@@ -552,10 +529,23 @@
.te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch {
width: 16px; height: 16px; border-radius: 4px;
width: 36px; height: 18px; border-radius: 5px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer;
position: relative;
overflow: hidden;
display: block;
}
.te-color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.te-color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0;
cursor: pointer;
padding: 0; border: none;
}
.te-token-name {
@@ -0,0 +1,309 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { store, updateSettings } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import type { Chapter } from "../../lib/types";
let { mangaId, chapters, onClose }: {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
} = $props();
const mangaPrefs = $derived(
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
);
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
}
const scanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={getPref("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={getPref("autoDownload")}
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("downloadAhead") === opt.value}
onclick={() => setPref("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
onclick={() => setPref("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={getPref("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={getPref("deleteOnRead")}
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if getPref("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("deleteDelayHours") === opt.value}
onclick={() => setPref("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={getPref("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={getPref("pauseUpdates")}
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("refreshInterval") === opt.value}
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
{#if scanlators.length > 1}
<div class="divider"></div>
<p class="section-label">Scanlator</p>
<div class="auto-row auto-row-align-start">
<div class="auto-info">
<span class="auto-label">Preferred scanlator</span>
<span class="auto-desc">Prioritise this group's chapters in the list</span>
</div>
<div class="scanlator-list">
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={!getPref("preferredScanlator")}
onclick={() => setPref("preferredScanlator", "")}
>Any</button>
{#each scanlators as s}
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={getPref("preferredScanlator") === s}
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
title={s}
>{s}</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
/* Header */
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* Body */
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
/* Section labels */
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
/* Rows — mirrors SeriesDetail auto-row */
.auto-row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3);
}
.auto-row-align-start { align-items: flex-start; }
.auto-row-sub {
padding-left: var(--sp-3);
border-left: 2px solid var(--border-dim);
}
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
/* Toggle */
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
/* Chips */
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* Scanlator list */
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+15 -14
View File
@@ -356,7 +356,7 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label}
</button>
{/if}
@@ -393,19 +393,18 @@
{#if !loadingDetail}
<div class="meta-table">
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
{#if !loadingChapters && firstUpload && lastUpload}
<div class="meta-row">
<span class="meta-key">Published</span>
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}</span>
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
</div>
<div class="meta-col">
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
</div>
</div>
{/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>
{/if}
</div>
@@ -528,9 +527,11 @@
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
+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>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries";
@@ -11,7 +12,7 @@
let search = $state("");
let expanded = $state(new Set<string>());
$effect(() => {
onMount(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes; })
.catch(console.error)
+70 -66
View File
@@ -19,8 +19,6 @@
onClose: () => void;
} = $props();
// ── State ──────────────────────────────────────────────────────────────────
type TabId = "records" | number;
let trackers: Tracker[] = $state([]);
@@ -39,8 +37,6 @@
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() {
loading = true;
try {
@@ -61,7 +57,6 @@
$effect(() => { load(); });
// Auto-search with manga title when switching to a tracker tab
$effect(() => {
const tab = activeTab;
if (typeof tab !== "number") return;
@@ -71,14 +66,10 @@
doSearch(tab, mangaTitle);
});
// ── Helpers ────────────────────────────────────────────────────────────────
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
// ── Search ─────────────────────────────────────────────────────────────────
let searchTimer: ReturnType<typeof setTimeout>;
function onSearchInput() {
@@ -105,8 +96,6 @@
}
}
// ── Bind / Unbind ──────────────────────────────────────────────────────────
async function bind(result: TrackSearch) {
if (typeof activeTab !== "number") return;
binding = true;
@@ -137,8 +126,6 @@
}
}
// ── Update ─────────────────────────────────────────────────────────────────
async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id;
try {
@@ -234,7 +221,6 @@
>
<div class="modal" role="dialog" aria-label="Tracking">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Tracking</span>
@@ -256,7 +242,6 @@
</div>
{:else}
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
<div class="tabs">
<button
class="tab"
@@ -282,7 +267,6 @@
{/each}
</div>
<!-- ── My List tab ───────────────────────────────────────────────────── -->
{#if activeTab === "records"}
<div class="tab-body">
{#if records.length === 0}
@@ -434,7 +418,6 @@
{/if}
</div>
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
{:else}
{@const tracker = trackerFor(activeTab as number)}
{@const boundRecord = recordFor(activeTab as number)}
@@ -517,8 +500,8 @@
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(580px, calc(100vw - 48px));
max-height: min(680px, calc(100vh - 80px));
width: min(560px, calc(100vw - 48px));
max-height: min(660px, calc(100vh - 80px));
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
@@ -532,9 +515,9 @@
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* States */
@@ -544,71 +527,93 @@
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
/* Tabs */
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab-active { color: var(--text-secondary); background: var(--bg-raised); }
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; }
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.tab {
display: flex; align-items: center; gap: var(--sp-2); position: relative;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 10px 10px 9px; color: var(--text-faint);
background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
}
.tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* Records */
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; }
.tab-body::-webkit-scrollbar { display: none; }
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
.record-busy { opacity: 0.5; pointer-events: none; }
.record-row {
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-md);
background: var(--bg-raised);
transition: opacity var(--t-base);
}
.record-row:hover { background: var(--bg-overlay); }
.record-busy { opacity: 0.45; pointer-events: none; }
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; }
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
.record-title:hover { opacity: 0.75; }
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center;
transition: border-color var(--t-base);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
.record-select:focus { border-color: var(--accent); outline: none; }
.record-select:disabled { opacity: 0.4; cursor: default; }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 100px; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
.record-select-score { max-width: 90px; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
.record-progress.clickable:hover .edit-hint { opacity: 1; }
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
/* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
.chapter-input:focus { border-color: var(--accent); }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); }
/* Search */
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); }
@@ -618,17 +623,16 @@
/* Results */
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; }
.result-bound { background: var(--accent-muted) !important; }
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); }
.hidden { display: none; }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
+107
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 { fetchAuthenticated } from "./auth";
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;
}
function getAuthHeader(): Record<string, string> {
const s = store.settings;
if (!s.serverAuthEnabled) return {};
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
return {};
}
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
const base = getServerUrl();
const mode = store.settings.serverAuthMode;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
const url = new URL(`${base}${path}`);
url.username = user;
url.password = pass;
return url.toString();
}
}
return `${base}${path}`;
}
interface GQLResponse<T> {
@@ -51,12 +58,12 @@ async function fetchWithRetry(
for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetch(url, { ...init, signal });
const res = await fetchAuthenticated(url, init, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e: any) {
if (e?.authRequired) throw e;
const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e;
@@ -73,7 +80,7 @@ export async function gql<T>(
): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeader() },
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
}, signal);
+15 -17
View File
@@ -5,6 +5,8 @@ import type { Manga, Chapter } from "./types";
const APP_ID = "1487894643613106298";
const FALLBACK_IMAGE = "moku_logo";
let sessionStart: number | null = null;
function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === "string" && url.startsWith("https://");
}
@@ -22,15 +24,18 @@ function formatChapter(chapter: Chapter): string {
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
}
function getTimestamps(): Timestamps {
return new Timestamps(sessionStart ?? Date.now());
}
const BUTTONS = [
new Button("GitHub", "https://github.com/Youwes09/Moku"),
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
];
export async function initRpc(): Promise<void> {
await start(APP_ID)
.then(() => console.log("[discord] RPC started"))
.catch((e) => console.error("[discord] initRpc failed:", e));
sessionStart = Date.now();
await start(APP_ID).catch(() => {});
}
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
@@ -44,12 +49,10 @@ export async function setReading(manga: Manga, chapter: Chapter): Promise<void>
.setDetails(trunc(manga.title))
.setState(`${formatChapter(chapter)} · Reading`)
.setAssets(assets)
.setTimestamps(new Timestamps(Date.now()));
.setTimestamps(getTimestamps());
activity.setButton(BUTTONS);
await setActivity(activity)
.then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter)))
.catch((e) => console.error("[discord] setActivity failed:", e));
await setActivity(activity).catch(() => {});
}
export async function setIdle(): Promise<void> {
@@ -60,22 +63,17 @@ export async function setIdle(): Promise<void> {
const activity = new Activity()
.setDetails("Browsing")
.setAssets(assets)
.setTimestamps(new Timestamps(Date.now()));
.setTimestamps(getTimestamps());
activity.setButton(BUTTONS);
await setActivity(activity)
.then(() => console.log("[discord] idle"))
.catch((e) => console.error("[discord] setActivity failed (idle):", e));
await setActivity(activity).catch(() => {});
}
export async function clearReading(): Promise<void> {
await clearActivity()
.then(() => console.log("[discord] activity cleared"))
.catch((e) => console.error("[discord] clearActivity failed:", e));
await clearActivity().catch(() => {});
}
export async function destroyRpc(): Promise<void> {
await stop()
.then(() => console.log("[discord] RPC stopped"))
.catch((e) => console.error("[discord] destroyRpc failed:", e));
sessionStart = null;
await stop().catch(() => {});
}
+6
View File
@@ -12,6 +12,8 @@ export interface Keybinds {
togglePageStyle: string;
toggleFullscreen: string;
openSettings: string;
toggleBookmark: string;
toggleMarker: string;
}
export const DEFAULT_KEYBINDS: Keybinds = {
@@ -26,6 +28,8 @@ export const DEFAULT_KEYBINDS: Keybinds = {
togglePageStyle: "q",
toggleFullscreen: "f",
openSettings: "o",
toggleBookmark: "m",
toggleMarker: "n",
};
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -40,6 +44,8 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
};
export function eventToKeybind(e: KeyboardEvent): string {
+36 -2
View File
@@ -187,6 +187,23 @@ export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings {
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 = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas {
id
title
@@ -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
}
}
`;
+82 -11
View File
@@ -8,29 +8,100 @@ export function cn(...inputs: ClassValue[]) {
// ── NSFW genre filtering ──────────────────────────────────────────────────────
/**
* Genre tags that indicate adult/mature content.
* Checked case-insensitively against each manga's genre array.
* Extend this set if additional tags need to be covered.
* Default substrings used when no user-configured list is available.
* The Settings > Content tab lets users add/remove entries from this list,
* which is stored as settings.nsfwFilteredTags.
*/
const NSFW_GENRE_TAGS = new Set([
export const DEFAULT_NSFW_TAGS = [
"adult",
"mature",
"hentai",
"ecchi",
"erotica",
"pornographic",
"erotic", // catches "erotica", "erotic content", "erotic manga"
"pornograph", // catches "pornographic", "pornography"
"18+",
"smut",
"lemon",
"explicit",
]);
"sexual violence",
];
/**
* Returns true if the manga carries at least one genre tag that is considered
* adult/mature. Used to enforce the `showNsfw` setting across all views.
* Returns true if the manga carries at least one genre tag matching any of
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
*/
export function isNsfwManga(manga: { genre?: string[] | null }): boolean {
return (manga.genre ?? []).some((g) => NSFW_GENRE_TAGS.has(g.toLowerCase().trim()));
export function isNsfwManga(
manga: { genre?: string[] | null },
tags: string[] = DEFAULT_NSFW_TAGS,
): boolean {
return (manga.genre ?? []).some((g) => {
const normalized = g.toLowerCase().trim();
return tags.some((sub) => normalized.includes(sub));
});
}
/**
* Single authoritative NSFW gate used by all views.
*
* Returns true when the manga should be HIDDEN. Checks in order:
* 1. showNsfw disabled globally skip everything, hide by source flag or genre match.
* 2. Source is in blockedSourceIds always hide regardless of showNsfw.
* 3. Source is in allowedSourceIds always show (bypasses isNsfw flag only, genre tags still apply).
* 4. Source isNsfw flag hide unless source is allowed.
* 5. Genre tag match hide.
*
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/
export function shouldHideNsfw(
manga: {
genre?: string[] | null;
source?: { id?: string; isNsfw?: boolean } | null;
},
settings: {
showNsfw: boolean;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean {
const srcId = manga.source?.id;
// Explicit block always wins, even when showNsfw is on
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
// If NSFW is globally allowed, only explicit blocks apply
if (settings.showNsfw) return false;
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
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 ──────────────────────────────────────────────────────
+160 -101
View File
@@ -27,35 +27,38 @@ export type LibraryStatusFilter =
| "CANCELLED"
| "HIATUS"
| "UNKNOWN";
export type LibraryContentFilter =
| "unread"
| "started"
| "downloaded"
| "bookmarked"
| "marked";
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 {
/* Backgrounds */
"bg-void": string;
"bg-base": string;
"bg-surface": string;
"bg-raised": string;
"bg-overlay": string;
"bg-subtle": string;
/* Borders */
"border-dim": string;
"border-base": string;
"border-strong": string;
"border-focus": string;
/* Text */
"text-primary": string;
"text-secondary": string;
"text-muted": string;
"text-faint": string;
"text-disabled": string;
/* Accent */
"accent": string;
"accent-dim": string;
"accent-muted": string;
"accent-fg": string;
"accent-bright": string;
/* Semantic */
"color-error": string;
"color-error-bg": string;
"color-success": string;
@@ -64,7 +67,7 @@ export interface ThemeTokens {
}
export interface CustomTheme {
id: string; // "custom:abc123"
id: string;
name: string;
tokens: ThemeTokens;
}
@@ -97,28 +100,46 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"color-info-bg": "#121a1f",
};
export interface HistoryEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
readAt: number;
}
/**
* ReadLogEntry append-only record of every chapter-completion event.
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
* this log never overwrites existing entries. It is the source of truth
* for all reading stats.
*/
export interface BookmarkEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
savedAt: number;
label?: string;
}
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
export interface MarkerEntry {
id: string;
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
note: string;
color: MarkerColor;
createdAt: number;
updatedAt?: number;
}
export interface ReadLogEntry {
mangaId: number;
chapterId: number;
readAt: number;
/** Minutes spent on this chapter (estimated from page count or default). */
minutes: number;
}
@@ -133,7 +154,7 @@ export interface ReadingStats {
lastStreakDate: string;
}
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available
const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0,
@@ -160,16 +181,32 @@ export interface ActiveDownload {
progress: number;
}
export interface MangaPrefs {
autoDownload: boolean;
downloadAhead: number;
deleteOnRead: boolean;
deleteDelayHours: number;
maxKeepChapters: number;
pauseUpdates: boolean;
refreshInterval: "global" | "daily" | "weekly" | "manual";
preferredScanlator: string;
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "global",
preferredScanlator: "",
};
export interface Settings {
pageStyle: PageStyle;
readingDirection: ReadingDirection;
fitMode: FitMode;
/**
* Reader zoom level unitless float multiplier relative to the viewer
* container width. 1.0 = image fills the viewer, 1.5 = 150%, 0.8 = 80%.
* Replaces the old `maxPageWidth` pixel value.
*/
readerZoom: number;
pageGap: boolean;
optimizeContrast: boolean;
@@ -184,11 +221,6 @@ export interface Settings {
chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode;
chapterPageSize: number;
/**
* UI zoom level unitless float multiplier applied on top of the
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
* Replaces the old `uiScale` percentage integer.
*/
uiZoom: number;
compactSidebar: boolean;
gpuAcceleration: boolean;
@@ -202,14 +234,16 @@ export interface Settings {
storageLimitGb: number | null;
markReadOnNext: boolean;
readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string;
serverAuthPass: string;
serverAuthEnabled: boolean;
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
socksProxyEnabled: boolean;
socksProxyHost: string;
socksProxyPort: string;
@@ -226,19 +260,20 @@ export interface Settings {
appLockPin: string;
customThemes: CustomTheme[];
hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null;
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
// Legacy fields kept for migration reads only — never written after v3.
/** @deprecated use readerZoom */
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number;
/** @deprecated use uiZoom */
uiScale?: number;
extraScanDirs: string[];
serverDownloadsPath: string;
serverLocalSourcePath: string;
}
export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip",
readingDirection: "ltr",
@@ -270,14 +305,16 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null,
markReadOnNext: true,
readerDebounceMs: 120,
autoBookmark: true,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "",
serverAuthPass: "",
serverAuthEnabled: false,
serverAuthMode: "NONE",
socksProxyEnabled: false,
socksProxyHost: "",
socksProxyPort: "1080",
@@ -295,16 +332,19 @@ export const DEFAULT_SETTINGS: Settings = {
customThemes: [],
hiddenCategoryIds: [],
defaultLibraryCategoryId: null,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
libraryTabSort: {},
libraryTabStatus: {},
libraryTabFilters: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
};
// ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 3;
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary",
"readerZoom",
@@ -355,10 +395,15 @@ function mergeSettings(saved: any): Settings {
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {},
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
};
}
@@ -373,27 +418,16 @@ function todayStr(): string {
const genId = () => Math.random().toString(36).slice(2, 10);
// ── Store ─────────────────────────────────────────────────────────────────────
class Store {
navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state("library");
history: HistoryEntry[] = $state(saved?.history ?? []);
/**
* readLog append-only, never deduped. Every chapter completion/progress
* event lands here. This is the authoritative source for all reading stats.
* Capped at 5 000 entries; oldest are trimmed first.
*/
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
markers: MarkerEntry[] = $state(saved?.markers ?? []);
readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved));
/**
* Bumped each time the reader closes. Home.svelte watches this to know
* when to re-fetch library data and refresh the hero section.
*/
readerSessionId: number = $state(0);
genreFilter: string = $state("");
searchPrefill: string = $state("");
activeManga: Manga | null = $state(null);
@@ -407,18 +441,8 @@ class Store {
toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]);
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
isFullscreen: boolean = $state(false);
// ── Shared category list ──────────────────────────────────────────────────
// Single source of truth for the category list, shared between Library and
// Settings. Library owns fetching; Settings reads and mutates in-place.
// No pub/sub or guard flags needed — both components share this $state ref.
categories: Category[] = $state([]);
// ── Discover session cache ────────────────────────────────────────────────
// Survives navigation within a session but is never persisted to localStorage.
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
discoverCache: Map<string, Manga[]> = $state(new Map());
discoverLibraryIds: Set<number> = $state(new Set());
discoverSrcOffset: number = $state(0);
@@ -430,12 +454,15 @@ class Store {
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
$effect(() => { persist({ history: this.history }); });
$effect(() => { persist({ readLog: this.readLog }); });
$effect(() => { persist({ bookmarks: this.bookmarks }); });
$effect(() => { persist({ markers: this.markers }); });
$effect(() => { persist({ readingStats: this.readingStats }); });
$effect(() => { persist({ settings: this.settings }); });
});
}
openReader(chapter: Chapter, chapterList: Chapter[]) {
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
if (manga) this.activeManga = manga;
this.activeChapter = chapter;
this.activeChapterList = chapterList;
this.pageUrls = [];
@@ -443,36 +470,20 @@ class Store {
}
closeReader() {
// Null activeChapter FIRST so the history $effect in Reader can't fire
// one last time with stale chapter + pageNumber=1, overwriting the real
// last-read position with page 1.
this.activeChapter = null;
this.activeChapterList = [];
this.pageUrls = [];
this.pageNumber = 1;
this.readerSessionId += 1; // signals Home to refresh
this.readerSessionId += 1;
}
/**
* Record a reading event.
*
* @param entry - The history entry for the "continue reading" UI.
* @param completed - True when the chapter was fully read (triggers stat
* accrual). False for mid-chapter progress updates.
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
*/
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
// ── 1. Update the deduped "continue reading" history ──────────────────
// Always keep the latest position for each chapter at the top.
if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
this.history[0] = { ...this.history[0], readAt: entry.readAt };
} else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
}
// ── 2. Append to the read log (only on completion) ────────────────────
// This is append-only — every completed chapter read lands here,
// including re-reads. We cap at 5 000 to keep storage bounded.
if (completed) {
const logEntry: ReadLogEntry = {
mangaId: entry.mangaId,
@@ -483,12 +494,7 @@ class Store {
this.readLog = [...this.readLog, logEntry].slice(-5000);
}
// ── 3. Recompute stats from the read log ──────────────────────────────
// Use the log as ground truth so stats are always accurate even after
// history is cleared or entries are back-filled.
const log = completed
? [...this.readLog] // already updated above
: this.readLog;
const log = completed ? [...this.readLog] : this.readLog;
const uniqueChapters = new Set(log.map(e => e.chapterId));
const uniqueManga = new Set(log.map(e => e.mangaId));
@@ -517,11 +523,64 @@ class Store {
};
}
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [
bookmark,
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200);
}
removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
}
clearBookmarks() {
this.bookmarks = [];
}
getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId);
}
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
const id = genId();
const marker: MarkerEntry = { ...entry, id, createdAt: Date.now() };
this.markers = [marker, ...this.markers].slice(0, 2000);
return id;
}
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
this.markers = this.markers.map(m =>
m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m
);
}
removeMarker(id: string) {
this.markers = this.markers.filter(m => m.id !== id);
}
getMarkersForPage(chapterId: number, page: number): MarkerEntry[] {
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
}
getMarkersForChapter(chapterId: number): MarkerEntry[] {
return this.markers.filter(m => m.chapterId === chapterId);
}
getMarkersForManga(mangaId: number): MarkerEntry[] {
return this.markers.filter(m => m.mangaId === mangaId);
}
clearMarkersForManga(mangaId: number) {
this.markers = this.markers.filter(m => m.mangaId !== mangaId);
}
clearHistory() { this.history = []; this.readLog = []; }
clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId);
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
// Recompute stats after removal
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
@@ -536,11 +595,11 @@ class Store {
wipeAllData() {
this.history = [];
this.readLog = [];
this.markers = [];
this.readingStats = { ...DEFAULT_READING_STATS };
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
}
linkManga(idA: number, idB: number) {
if (idA === idB) return;
const links = { ...this.settings.mangaLinks };
@@ -587,7 +646,6 @@ class Store {
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
saveCustomTheme(theme: CustomTheme) {
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
const next = existing >= 0
@@ -602,13 +660,6 @@ class Store {
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
}
/**
* Auto-assign or remove the "Completed" category for a manga based on
* whether all chapters are read. Pass the `gql` executor to avoid a
* circular import between state.svelte.ts and client.ts.
*
* Call after any batch mark-read/unread operation.
*/
async checkAndMarkCompleted(
mangaId: number,
chaps: Chapter[],
@@ -623,7 +674,6 @@ class Store {
if (!completed) return;
if (allRead) {
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
// Ensure the manga is in the library so it shows up in the Saved tab
if (UPDATE_MANGA) {
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
}
@@ -647,9 +697,7 @@ class Store {
export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ──────────────────
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
export function closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); }
@@ -677,6 +725,17 @@ export function setSettingsOpen(next: boolean) { store
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); }
export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return store.addMarker(entry); }
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { store.updateMarker(id, patch); }
export function removeMarker(id: string) { store.removeMarker(id); }
export function getMarkersForPage(chapterId: number, page: number) { return store.getMarkersForPage(chapterId, page); }
export function getMarkersForChapter(chapterId: number) { return store.getMarkersForChapter(chapterId); }
export function getMarkersForManga(mangaId: number) { return store.getMarkersForManga(mangaId); }
export function clearMarkersForManga(mangaId: number) { store.clearMarkersForManga(mangaId); }
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }