Compare commits

..

9 Commits

Author SHA1 Message Date
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
Youwes09 8aa2dc2547 Chore: Prepare for Version 0.6.0 2026-03-29 15:47:12 -05:00
Youwes09 0a11fe3982 Feat: Discord RPC 2026-03-29 15:38:39 -05:00
Youwes09 f6786def87 Fix: SeriesDetail passing Incorrect Args to Reader 2026-03-29 14:03:28 -05:00
Youwes09 262027d9f9 Feat: Added Filtering System in Library (Request: #13) 2026-03-29 13:22:08 -05:00
Youwes09 d407359973 Fix: Added Slight Border to Mitigate Windows Tab Issue (WIP) 2026-03-29 12:58:03 -05:00
Youwes09 a77572a8d4 Fix: Constrained Home-Screen Completed & SplashScreen #15 2026-03-29 12:51:17 -05:00
Youwes09 32d2fffdc5 Fix: Zoom Issue (Bug #14) 2026-03-29 12:40:28 -05:00
27 changed files with 1576 additions and 796 deletions
+9 -9
View File
@@ -1,21 +1,18 @@
Major Revisions: Major Revisions:
- Moku + Crossplatform Support (MacOS Remaining)
- Contemplate Anime Support, Add Novel Support (Consumet API) - Contemplate Anime Support, Add Novel Support (Consumet API)
- Enable Cloudflare Bypass (Suwayomi Config)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - 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: Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive) - Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- Integrate Download Directory Changes (Settings) - Integrate Download Directory Changes (Settings)
- Investigate feasibility of Multi-Page Screenshot (Reader) - Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks) - Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
Priority Bugs: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - Cache ALL Cover Pictures & Details for Manga in Library
- MacOS Full-Screen & UI Compatability (TitleBar) - Investigate Zoom (Reader), Appears to have Cutoff, etc.
General/Misc Bugs: General/Misc Bugs:
- Fix Highlightable Elements - Fix Highlightable Elements
@@ -25,9 +22,12 @@ General/Misc Bugs:
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?) - Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress:` In-Progress:`
- Fix Reader Chapter Shifts (Glitched Sentinel) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Still Shifts Down after reading ~8+ Chapters? - Fix NSFW Parsing (Appears to not Work???)
- Identify When Chapters are Unloaded, How to Preserve Structure
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
Important Commands: Important Commands:
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: a6b7b0f57210ea15b1a7ef580be9f89a667d647373abca4f34fe017a5ac8c850 sha256: 9e9590cf8c98b07ca774382491b1d8cfcc1f2151afadbf8e23e2abda0c086c11
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+2 -2
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.5.1"; version = "0.6.0";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -71,7 +71,7 @@
inherit version; inherit version;
src = frontendSrc; src = frontendSrc;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q="; hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg=";
}; };
buildPhase = "pnpm build"; buildPhase = "pnpm build";
+2 -1
View File
@@ -15,7 +15,8 @@
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0", "phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1",
"tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^4.0.4",
+59 -20
View File
@@ -2225,14 +2225,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.11.crate", "url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
"sha256": "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb", "sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
"dest": "cargo/vendor/iri-string-0.7.11" "dest": "cargo/vendor/iri-string-0.7.12"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb\", \"files\": {}}", "contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.11", "dest": "cargo/vendor/iri-string-0.7.12",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4201,14 +4201,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.1.crate", "url": "https://static.crates.io/crates/rpcdiscord/rpcdiscord-0.2.6.crate",
"sha256": "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d", "sha256": "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d",
"dest": "cargo/vendor/rustc-hash-2.1.1" "dest": "cargo/vendor/rpcdiscord-0.2.6"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\", \"files\": {}}", "contents": "{\"package\": \"71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d\", \"files\": {}}",
"dest": "cargo/vendor/rustc-hash-2.1.1", "dest": "cargo/vendor/rpcdiscord-0.2.6",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustc-hash/rustc-hash-2.1.2.crate",
"sha256": "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe",
"dest": "cargo/vendor/rustc-hash-2.1.2"
},
{
"type": "inline",
"contents": "{\"package\": \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\", \"files\": {}}",
"dest": "cargo/vendor/rustc-hash-2.1.2",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -5238,6 +5251,19 @@
"dest": "cargo/vendor/tauri-plugin-2.5.4", "dest": "cargo/vendor/tauri-plugin-2.5.4",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -6018,6 +6044,19 @@
"dest": "cargo/vendor/utf8_iter-1.0.4", "dest": "cargo/vendor/utf8_iter-1.0.4",
"dest-filename": ".cargo-checksum.json" "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", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -7503,27 +7542,27 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.47.crate", "url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.48.crate",
"sha256": "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87", "sha256": "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9",
"dest": "cargo/vendor/zerocopy-0.8.47" "dest": "cargo/vendor/zerocopy-0.8.48"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87\", \"files\": {}}", "contents": "{\"package\": \"eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-0.8.47", "dest": "cargo/vendor/zerocopy-0.8.48",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.47.crate", "url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.48.crate",
"sha256": "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89", "sha256": "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4",
"dest": "cargo/vendor/zerocopy-derive-0.8.47" "dest": "cargo/vendor/zerocopy-derive-0.8.48"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89\", \"files\": {}}", "contents": "{\"package\": \"70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-derive-0.8.47", "dest": "cargo/vendor/zerocopy-derive-0.8.48",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
+12
View File
@@ -26,6 +26,9 @@ importers:
svelte-spa-router: svelte-spa-router:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2 version: 4.0.2
tauri-plugin-drpc:
specifier: ^1.0.3
version: 1.0.3(typescript@5.9.3)
devDependencies: devDependencies:
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4 specifier: ^4.0.4
@@ -744,6 +747,11 @@ packages:
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tauri-plugin-drpc@1.0.3:
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
peerDependencies:
typescript: ^5.0.0
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -1364,6 +1372,10 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
zimmerframe: 1.1.4 zimmerframe: 1.1.4
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
dependencies:
typescript: 5.9.3
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
+46 -7
View File
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid", "uuid 1.23.0",
] ]
[[package]] [[package]]
@@ -1752,9 +1752,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
version = "0.7.11" version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
@@ -2112,6 +2112,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-drpc",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
@@ -3346,6 +3347,19 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpcdiscord"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"uuid 0.8.2",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3490,7 +3504,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"url", "url",
"uuid", "uuid 1.23.0",
] ]
[[package]] [[package]]
@@ -4270,7 +4284,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"time", "time",
"url", "url",
"uuid", "uuid 1.23.0",
"walkdir", "walkdir",
] ]
@@ -4305,6 +4319,22 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-drpc"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a"
dependencies = [
"log",
"rpcdiscord",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.4.5" version = "2.4.5"
@@ -4518,7 +4548,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
"uuid", "uuid 1.23.0",
"walkdir", "walkdir",
] ]
@@ -5023,6 +5053,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.23.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.5.1" version = "0.6.0"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -26,6 +26,7 @@ walkdir = "2"
sysinfo = "0.32" sysinfo = "0.32"
dirs = "5" dirs = "5"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
tauri-plugin-drpc = "0.1.6"
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
+7 -1
View File
@@ -32,6 +32,12 @@
"process:default", "process:default",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch" "http:allow-fetch",
"drpc:default",
"drpc:allow-is-running",
"drpc:allow-spawn-thread",
"drpc:allow-destroy-thread",
"drpc:allow-set-activity",
"drpc:allow-clear-activity"
] ]
} }
+109 -39
View File
@@ -104,14 +104,14 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// 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.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command] #[tauri::command]
fn get_platform_ui_scale() -> f64 { fn get_platform_ui_scale(window: tauri::Window) -> f64 {
#[cfg(target_os = "windows")] window.scale_factor().unwrap_or(1.0)
return 1.0;
#[cfg(target_os = "macos")]
return 1.0;
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return 1.5;
} }
fn kill_tachidesk(app: &tauri::AppHandle) { fn kill_tachidesk(app: &tauri::AppHandle) {
@@ -248,10 +248,16 @@ struct ServerInvocation {
working_dir: Option<PathBuf>, 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"))] #[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> { fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let java = bundle_dir.join("jre").join("bin").join("java.exe"); let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java"); let java = bundle_dir.join("jre").join("bin").join("java");
@@ -276,28 +282,35 @@ fn resolve_server_binary(
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary)); do_log(log, &format!("[resolve] binary arg = {:?}", binary));
// ── 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() { if !binary.trim().is_empty() {
do_log(log, "[resolve] using user-supplied binary path"); let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
if path.exists() {
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: binary.to_string(), bin: path.to_string_lossy().into_owned(),
args: vec![], args: vec![],
working_dir: None, working_dir: path.parent().map(|p| p.to_path_buf()),
}); });
} }
// 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");
}
let resource_dir = match app.path().resource_dir() { // Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
Ok(p) => { #[cfg(not(target_os = "macos"))]
let stripped = strip_unc(p); 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)); do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
stripped stripped
}
Err(e) => {
let msg = format!("resource_dir error: {e}");
do_log(log, &format!("[resolve] ERROR: {}", msg));
return Err(SpawnError::SpawnFailed(msg));
}
}; };
// ── 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"))] #[cfg(not(target_os = "macos"))]
{ {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
@@ -315,45 +328,91 @@ fn resolve_server_binary(
do_log(log, "[resolve] both java and jar found — using bundled JRE"); do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(), bin: java.to_string_lossy().into_owned(),
args: vec![ args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(bundle_dir), working_dir: Some(bundle_dir),
}); });
} else {
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
} }
do_log(log, "[resolve] java found but jar MISSING — falling through");
} }
None => { None => {
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path"); 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() {
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
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 — MacOS/ then Resources/ ──────────────────────────
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Tauri places externalBin sidecars next to the main binary in let resource_dir = app.path().resource_dir().unwrap_or_default();
// Contents/MacOS/, not in Contents/Resources/. Derive that path let macos_dir = resource_dir
// from resource_dir (Contents/Resources → Contents/MacOS). .parent()
let macos_dir = resource_dir.join("../MacOS") .map(|p| p.join("MacOS"))
.canonicalize() .unwrap_or_default();
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir)); do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
// Tauri strips the target triple when installing externalBin sidecars // Tauri strips the target triple when installing externalBin sidecars into
// into Contents/MacOS/, so the binary is always just "suwayomi-server" // Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
// at runtime. The triple-suffixed names are only needed on disk at // Triple-suffixed names are kept as a belt-and-suspenders fallback for
// build time for Tauri to pick the right arch during bundling. // dev / flat layouts.
let candidates = [ let candidates = [
"suwayomi-server", "suwayomi-server",
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-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 search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates { for name in &candidates {
let p = search_dir.join(name); let p = search_dir.join(name);
@@ -370,8 +429,18 @@ fn resolve_server_binary(
} }
} }
// ── 4. PATH fallback ──────────────────────────────────────────────────────
// Use `where` on Windows, `which` everywhere else.
do_log(log, "[resolve] trying PATH fallback"); do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] { 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") let found = std::process::Command::new("which")
.arg(name) .arg(name)
.output() .output()
@@ -573,6 +642,7 @@ fn restart_app(app: tauri::AppHandle) {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_drpc::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.5.1", "version": "0.6.0",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+35 -7
View File
@@ -7,6 +7,7 @@
import { gql } from "./lib/client"; import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte"; import Layout from "./components/layout/Layout.svelte";
import Reader from "./components/reader/Reader.svelte"; import Reader from "./components/reader/Reader.svelte";
@@ -74,13 +75,16 @@
let notConfigured = $state(false); let notConfigured = $state(false);
let idle = $state(false); let idle = $state(false);
let devSplash = $state(false); let devSplash = $state(false);
let platformScale = $state(1);
let platformScale = $state(1.0);
function applyZoom() { function applyZoom() {
const normalized = store.settings.uiScale * platformScale; const uiZoom = store.settings.uiZoom ?? 1.5;
document.documentElement.style.zoom = `${normalized}%`; const effective = platformScale * uiZoom;
document.documentElement.style.setProperty("--ui-scale", String(normalized)); const pct = effective * 100;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`); document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -126,7 +130,7 @@
}); });
$effect(() => { $effect(() => {
store.settings.uiScale; platformScale; store.settings.uiZoom; platformScale;
applyZoom(); applyZoom();
}); });
@@ -214,14 +218,20 @@
document.addEventListener("contextmenu", e => e.preventDefault()); document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true; (window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1); platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom(); applyZoom();
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => { const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
}); });
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") { if (err?.kind === "NotConfigured") {
@@ -240,6 +250,8 @@
return () => { return () => {
cancelProbe = true; cancelProbe = true;
unlistenResize(); unlistenResize();
unlistenScale();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
@@ -254,6 +266,22 @@
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
// When the reader closes, show idle presence.
$effect(() => {
if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle();
}
});
function handleRetry() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
+1 -1
View File
@@ -247,7 +247,7 @@
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta); const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr); ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr; const sw = stamps[i].width, sh = stamps[i].height;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
} }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
+9 -3
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte"; import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
@@ -60,7 +60,11 @@
} }
function filterOut(mangas: Manga[]): Manga[] { function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id))); return dedup(mangas.filter(m => {
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
if (shouldHideNsfw(m, store.settings)) return false;
return true;
}));
} }
function rotatedSources(): Source[] { function rotatedSources(): Source[] {
@@ -183,7 +187,9 @@
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const local = dedup(d.mangas.nodes); const local = dedup(
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
);
store.discoverCache.set(localKey, local); store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT)); genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
+18 -9
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries"; import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte"; import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte"; import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
@@ -173,7 +173,11 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); 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); openReader(chapter, all);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -188,8 +192,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId }); 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 chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0]; const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters); if (ch) {
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; 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; } } catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -199,8 +205,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); 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 chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0]; const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters); if (ch) {
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; 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; } } catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
@@ -219,7 +227,7 @@
const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []); const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]); const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []); const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
const recentHistory = $derived(store.history.slice(0, 6)); const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats); const stats = $derived(store.readingStats);
@@ -415,7 +423,7 @@
<div class="bottom-section-hd"> <div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span> <span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0} {#if completedManga.length > 0}
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button> <button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
{/if} {/if}
</div> </div>
{#if completedManga.length > 0} {#if completedManga.length > 0}
@@ -582,9 +590,10 @@
.bottom-col:last-child { padding-left: var(--sp-4); } .bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); } .bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; } .bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); } .mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; } .mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); } .mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
+271 -17
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut } from "phosphor-svelte"; import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries"; import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
import type { Manga, Category, Chapter } from "../../lib/types"; import type { Manga, Category, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -19,6 +20,85 @@
let activeDragKind: "tab" | null = $state(null); let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
// ── Sort / filter panel ───────────────────────────────────────────────────
let sortPanelOpen: boolean = $state(false);
let filterPanelOpen: boolean = $state(false);
function openSortPanel() {
sortPanelOpen = !sortPanelOpen;
filterPanelOpen = false;
}
function openFilterPanel() {
filterPanelOpen = !filterPanelOpen;
sortPanelOpen = false;
}
const SORT_LABELS: Record<LibrarySortMode, string> = {
az: "AZ",
unreadCount: "Unread chapters",
totalChapters: "Total chapters",
recentlyAdded: "Recently added",
recentlyRead: "Recently read",
latestFetched: "Latest fetched chapter",
latestUploaded: "Latest uploaded chapter",
};
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
ALL: "All statuses",
ONGOING: "Ongoing",
COMPLETED: "Completed",
CANCELLED: "Cancelled",
HIATUS: "Hiatus",
UNKNOWN: "Unknown",
};
const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded",
"recentlyRead", "latestFetched", "latestUploaded",
];
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
];
// Per-tab reactive state — $derived so Svelte tracks changes to libraryFilter and settings
const tabSortMode = $derived(
store.settings.libraryTabSort[store.libraryFilter]?.mode ?? "az" as LibrarySortMode
);
const tabSortDir = $derived(
store.settings.libraryTabSort[store.libraryFilter]?.dir ?? "asc" as LibrarySortDir
);
const tabStatus = $derived(
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
);
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[store.libraryFilter];
const newDir = dir ?? prev?.dir ?? "asc";
updateSettings({
libraryTabSort: {
...store.settings.libraryTabSort,
[store.libraryFilter]: { mode, dir: newDir },
},
});
}
function toggleTabSortDir() {
setTabSort(tabSortMode, tabSortDir === "asc" ? "desc" : "asc");
}
function setTabStatus(status: LibraryStatusFilter) {
updateSettings({
libraryTabStatus: {
...store.settings.libraryTabStatus,
[store.libraryFilter]: status,
},
});
filterPanelOpen = false;
}
let allManga: Manga[] = $state([]); let allManga: Manga[] = $state([]);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let error: string|null = $state(null); let error: string|null = $state(null);
@@ -157,7 +237,11 @@
async function loadData() { async function loadData() {
try { try {
const [nodes] = await Promise.all([loadLibrary(), reloadCategories()]); const [nodes] = await Promise.all([loadLibrary(), reloadCategories()]);
allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); const mapped = nodes.map((m: any) => ({
...m,
chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0,
}));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null; error = null;
} catch (e: any) { } catch (e: any) {
error = e.message; error = e.message;
@@ -221,16 +305,75 @@
const filtered = $derived((() => { const filtered = $derived((() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
const mode = tabSortMode;
const dir = tabSortDir;
const status = tabStatus;
// 1. Pick the right base list for this tab
let items: Manga[];
if (store.libraryFilter === "library") { if (store.libraryFilter === "library") {
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga; items = allManga;
} else if (store.libraryFilter === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else {
items = categoryMangaMap.get(Number(store.libraryFilter)) ?? [];
} }
if (store.libraryFilter === "downloaded") {
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0); // 2. NSFW filter — always applied before text search or sort
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items; items = items.filter(m => !shouldHideNsfw(m, store.settings));
// 3. Text search
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
// 4. Status filter
if (status !== "ALL") {
items = items.filter(m => {
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
return s === status;
});
} }
const id = Number(store.libraryFilter);
const items = categoryMangaMap.get(id) ?? []; // 5. Sort
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items; const recentlyReadMap = new Map<number, number>();
if (mode === "recentlyRead") {
for (const h of store.history) {
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
}
}
const sorted = [...items].sort((a, b) => {
let cmp = 0;
switch (mode) {
case "az":
cmp = a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
break;
case "unreadCount":
cmp = (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
break;
case "totalChapters":
cmp = (a.chapterCount ?? 0) - (b.chapterCount ?? 0);
break;
case "recentlyAdded":
// id is monotonically increasing on Suwayomi — higher = newer
cmp = a.id - b.id;
break;
case "recentlyRead": {
const ra = recentlyReadMap.get(a.id) ?? 0;
const rb = recentlyReadMap.get(b.id) ?? 0;
cmp = ra - rb;
break;
}
// latestFetched / latestUploaded: no per-manga date available at list level;
// fall back to id ordering so the option still does something sensible.
case "latestFetched":
case "latestUploaded":
cmp = a.id - b.id;
break;
}
return dir === "asc" ? cmp : -cmp;
});
return sorted;
})()); })());
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)))); const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
@@ -440,15 +583,31 @@
// Escape key exits select mode // Escape key exits select mode
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) {
sortPanelOpen = false; filterPanelOpen = false; return;
}
if (e.key === "Escape" && selectMode) exitSelectMode(); if (e.key === "Escape" && selectMode) exitSelectMode();
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) { if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) {
e.preventDefault(); e.preventDefault();
selectAll(); selectAll();
} }
} }
window.addEventListener("keydown", onKeyDown);
return () => { ro.disconnect(); unsub(); window.removeEventListener("keydown", onKeyDown); }; function onDocMouseDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (sortPanelOpen && !target.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
if (filterPanelOpen && !target.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
}
window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true);
return () => {
ro.disconnect();
unsub();
window.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onDocMouseDown, true);
};
}); });
</script> </script>
@@ -535,10 +694,85 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="header-right">
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" /> <MagnifyingGlass size={13} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} /> <input class="search" placeholder="Search" bind:value={search} />
</div> </div>
<!-- Sort panel -->
<div class="sort-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
title="Sort"
onclick={openSortPanel}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu">
<p class="panel-label">Sort by</p>
{#each ALL_SORT_MODES as m}
<button
class="panel-item"
class:panel-item-active={tabSortMode === m}
role="menuitem"
onclick={() => { setTabSort(m); sortPanelOpen = false; }}
>
{SORT_LABELS[m]}
{#if tabSortMode === m}
{#if tabSortDir === "asc"}
<CaretUp size={11} weight="bold" class="sort-caret" />
{:else}
<CaretDown size={11} weight="bold" class="sort-caret" />
{/if}
{/if}
</button>
{/each}
<button
class="panel-item dir-toggle"
role="menuitem"
onclick={toggleTabSortDir}
>
{tabSortDir === "asc" ? "Ascending" : "Descending"}
{#if tabSortDir === "asc"}
<CaretUp size={11} weight="bold" />
{:else}
<CaretDown size={11} weight="bold" />
{/if}
</button>
</div>
{/if}
</div>
<!-- Filter panel -->
<div class="filter-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={tabStatus !== "ALL"}
title="Filter by status"
onclick={openFilterPanel}
>
<Funnel size={15} weight={tabStatus !== "ALL" ? "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}
<button
class="panel-item"
class:panel-item-active={tabStatus === s}
role="menuitem"
onclick={() => setTabStatus(s)}
>
{STATUS_LABELS[s]}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div> </div>
<!-- ── Selection toolbar ───────────────────────────────────────────────── --> <!-- ── Selection toolbar ───────────────────────────────────────────────── -->
@@ -659,12 +893,12 @@
{/if} {/if}
<style> <style>
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; } .content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; } .branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; } .branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
@keyframes branchGrow { to { stroke-dashoffset: 0; } } @keyframes branchGrow { to { stroke-dashoffset: 0; } }
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; } .header { position: relative; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; }
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } .header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; } .tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
@@ -682,6 +916,27 @@
.search::placeholder { color: var(--text-faint); } .search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
/* ── Header right cluster ───────────────────────────────────────────────── */
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
/* ── Icon buttons (sort / filter triggers) ──────────────────────────────── */
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
.sort-panel-wrap,
.filter-panel-wrap { position: relative; }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-overlay, #1a1a1a); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: 6px; box-shadow: 0 12px 36px rgba(0,0,0,0.55), 0 2px 8px rgba(0,0,0,0.3); animation: fadeIn 0.1s ease both; }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
.panel-item:hover { background: var(--bg-subtle, #202020); color: var(--text-primary); }
.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; }
.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; }
/* ── Selection toolbar ──────────────────────────────────────────────────── */ /* ── Selection toolbar ──────────────────────────────────────────────────── */
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; } .select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); } .select-bar-left { display: flex; align-items: center; gap: var(--sp-3); }
@@ -705,15 +960,14 @@
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); } .bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
/* ── Grid & cards ───────────────────────────────────────────────────────── */ /* ── Grid & cards ───────────────────────────────────────────────────────── */
.grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); } .grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.07); } .card:hover .cover { filter: brightness(1.07); }
.card:hover .title { color: var(--text-primary); } .card:hover .title { color: var(--text-primary); }
/* In select mode, clicking always means "select", so use a checkbox cursor */
.card.select-mode { cursor: default; } .card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); } .card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); } .card.card-selected .title { color: var(--accent-fg); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; } .cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; }
.badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); } .badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
.badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); } .badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
+101 -293
View File
@@ -3,7 +3,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte"; import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
@@ -91,14 +91,11 @@
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort()); const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1); const hasMultipleLangs = $derived(availableLangs.length > 1);
// ── Keyword search ────────────────────────────────────────────────────────
let kw_query = $state(""); let kw_query = $state("");
let kw_submitted = $state(""); let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]); let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false); let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set()); let kw_selectedLangs: Set<string> = $state(new Set());
let kw_includeNsfw = $state(false);
let kw_inputEl: HTMLInputElement | null = $state(null); let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null; let kw_abortCtrl: AbortController | null = null;
@@ -124,7 +121,7 @@
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang)); filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!kw_includeNsfw) if (!store.settings.showNsfw)
filtered = filtered.filter((s) => !s.isNsfw); filtered = filtered.filter((s) => !s.isNsfw);
return filtered; return filtered;
} }
@@ -146,8 +143,9 @@
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal, FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
kw_results = kw_results.map((r) => kw_results = kw_results.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r, r.source.id === src.id ? { ...r, mangas, loading: false } : r,
); );
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return; if (ctrl.signal.aborted || e?.name === "AbortError") return;
@@ -169,8 +167,6 @@
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0)); const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading)); const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
// ── Tag search ────────────────────────────────────────────────────────────
let tag_activeTags: string[] = $state([]); let tag_activeTags: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND"); let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state(""); let tag_tagFilter = $state("");
@@ -243,7 +239,8 @@
ctrl.signal, ctrl.signal,
).then((d) => { ).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
tag_localResults = d.mangas.nodes; const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
tag_totalCount = d.mangas.totalCount; tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = (store.settings.renderLimit ?? 48); tag_localOffset = (store.settings.renderLimit ?? 48);
@@ -279,9 +276,10 @@
ps.add(1); ps.add(1);
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1); tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
tag_srcNextPage = new Map(tag_srcNextPage); tag_srcNextPage = new Map(tag_srcNextPage);
const matching = activeTags.length > 1 const matching = (activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas; : result.mangas
).filter((m) => !shouldHideNsfw(m, store.settings));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false; tag_loadingSourceSearch = false;
@@ -304,7 +302,7 @@
ctrl.signal, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
tag_localResults = [...tag_localResults, ...d.mangas.nodes]; const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48); tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) { } catch (e: any) {
@@ -340,9 +338,10 @@
ps.add(page); ps.add(page);
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1); tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
tag_srcNextPage = new Map(tag_srcNextPage); tag_srcNextPage = new Map(tag_srcNextPage);
const matching = tag_activeTags.length > 1 const matching = (tag_activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas; : result.mangas
).filter((m) => !shouldHideNsfw(m, store.settings));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
} }
@@ -367,9 +366,7 @@
} }
} }
// ── Source browse ───────────────────────────────────────────────────────── let src_selectedLang = $state(preferredLang || "all");
let src_selectedLang = $state("all");
let src_activeSource: Source | null = $state(null); let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]); let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false); let src_loadingBrowse = $state(false);
@@ -378,40 +375,33 @@
let src_hasNextPage = $state(false); let src_hasNextPage = $state(false);
let src_currentPage = $state(1); let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null; let src_abortCtrl: AbortController | null = null;
let src_langPocketOpen = $state(true);
let src_expandedGroups: Set<string> = $state(new Set());
// Group sources by displayName — sources with same name but different langs get grouped $effect(() => {
interface SourceGroup { if (!allSources.length) return;
name: string; const langs = new Set(allSources.map((s) => s.lang));
iconUrl: string; if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
sources: Source[]; src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
isNsfw: boolean;
} }
});
const src_visibleSources = $derived(src_selectedLang === "all" const src_visibleSources = $derived.by(() => {
? allSources const nsfw = (s: Source) => !store.settings.showNsfw && s.isNsfw;
: allSources.filter((s) => s.lang === src_selectedLang)); if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !nsfw(s));
const src_groupedSources = $derived.by(() => { }
const filtered = src_visibleSources; const map = new Map<string, Source>();
const map = new Map<string, SourceGroup>(); for (const s of allSources) {
for (const src of filtered) { if (nsfw(s)) continue;
const key = src.displayName; const key = s.name;
if (!map.has(key)) { const existing = map.get(key);
map.set(key, { name: src.displayName, iconUrl: src.iconUrl, sources: [], isNsfw: src.isNsfw }); if (!existing) { map.set(key, s); continue; }
if (s.lang === preferredLang || (!existing || (existing.lang !== preferredLang && s.lang < existing.lang))) {
map.set(key, s);
} }
map.get(key)!.sources.push(src);
} }
return Array.from(map.values()); return Array.from(map.values());
}); });
function srcToggleGroup(name: string) {
const next = new Set(src_expandedGroups);
if (next.has(name)) next.delete(name); else next.add(name);
src_expandedGroups = next;
}
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) { async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort(); src_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -422,7 +412,8 @@
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal, FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas]; const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.hasNextPage; src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page; src_currentPage = page;
} catch (e: any) { } catch (e: any) {
@@ -570,10 +561,6 @@
{/each} {/each}
</div> </div>
<div class="advancedDivider"></div> <div class="advancedDivider"></div>
<label class="advancedCheck">
<input type="checkbox" bind:checked={kw_includeNsfw} class="checkbox" />
Include NSFW sources
</label>
<div class="advancedFooter"> <div class="advancedFooter">
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""} Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
</div> </div>
@@ -867,27 +854,18 @@
<div class="splitRoot"> <div class="splitRoot">
<div class="splitSidebar"> <div class="splitSidebar">
<button class="langPocketToggle" onclick={() => (src_langPocketOpen = !src_langPocketOpen)}> <div class="srcLangRow">
<span class="langPocketLabel">Languages</span> <span class="langPocketLabel">Language</span>
<svg width="9" height="9" viewBox="0 0 256 256" fill="currentColor" <select
style="transition: transform 0.2s ease; transform: rotate({src_langPocketOpen ? 180 : 0}deg)" class="langSelect"
aria-hidden="true"> bind:value={src_selectedLang}
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/>
</svg>
</button>
{#if src_langPocketOpen}
<div class="langPocket">
{#each ["all", ...availableLangs] as lang (lang)}
<button
class="langChip"
class:langChipActive={src_selectedLang === lang}
onclick={() => (src_selectedLang = lang)}
> >
{lang === "all" ? "All" : lang.toUpperCase()} <option value="all">All</option>
</button> {#each availableLangs as lang (lang)}
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
{/each} {/each}
</select>
</div> </div>
{/if}
{#if loadingSources} {#if loadingSources}
<div class="splitLoading"> <div class="splitLoading">
@@ -897,52 +875,22 @@
</div> </div>
{:else} {:else}
<div class="splitList"> <div class="splitList">
{#each src_groupedSources as group (group.name)} {#each src_visibleSources as src (src.id)}
{#if group.sources.length === 1}
<button <button
class="splitItem splitItemSource" class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === group.sources[0].id}
onclick={() => srcSelectSource(group.sources[0])}
>
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{group.name}</span>
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{group.sources[0].lang.toUpperCase()}</span>
{#if group.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{:else}
<button
class="splitItem splitItemSource splitItemGroup"
class:splitItemGroupOpen={src_expandedGroups.has(group.name)}
onclick={() => srcToggleGroup(group.name)}
>
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{group.name}</span>
<span class="groupLangCount">{group.sources.length}</span>
<svg width="8" height="8" viewBox="0 0 256 256" fill="currentColor"
class="groupChevron"
style="transform: rotate({src_expandedGroups.has(group.name) ? 180 : 0}deg)"
aria-hidden="true">
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/>
</svg>
</button>
{#if src_expandedGroups.has(group.name)}
{#each group.sources as src (src.id)}
<button
class="splitItem splitItemSource splitItemLangOption"
class:splitItemActive={src_activeSource?.id === src.id} class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)} onclick={() => srcSelectSource(src)}
> >
<span class="langOptionDot"></span> <img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon"
<span class="splitItemLabel">{src.lang.toUpperCase()}</span> onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"}
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
{/if}
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if} {#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button> </button>
{/each} {/each}
{/if} {#if src_visibleSources.length === 0}
{/if}
{/each}
{#if src_groupedSources.length === 0}
<p class="splitEmpty">No sources for this language</p> <p class="splitEmpty">No sources for this language</p>
{/if} {/if}
</div> </div>
@@ -1061,8 +1009,6 @@
</div> </div>
<style> <style>
/* ── Root ──────────────────────────────────────────────────────────────── */
.root { .root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1070,9 +1016,6 @@
overflow: hidden; overflow: hidden;
animation: fadeIn 0.14s ease both; animation: fadeIn 0.14s ease both;
} }
/* ── Header ────────────────────────────────────────────────────────────── */
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1081,7 +1024,6 @@
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
} }
.heading { .heading {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1090,9 +1032,6 @@
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
} }
/* ── Tabs ──────────────────────────────────────────────────────────────── */
.tabs { .tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
@@ -1101,7 +1040,6 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 2px; padding: 2px;
} }
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1113,23 +1051,19 @@
padding: 4px 10px; padding: 4px 10px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: none; background: none;
border: none; border: 1px solid transparent;
color: var(--text-faint); color: var(--text-faint);
cursor: pointer; cursor: pointer;
transition: background var(--t-base), color var(--t-base); transition: background var(--t-base), color var(--t-base);
white-space: nowrap; white-space: nowrap;
} }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tabActive { .tabActive {
background: var(--accent-muted); background: var(--accent-muted);
color: var(--accent-fg); color: var(--accent-fg);
border: 1px solid var(--accent-dim); border: 1px solid var(--accent-dim);
} }
.tabActive:hover { color: var(--accent-fg); } .tabActive:hover { color: var(--accent-fg); }
/* ── Keyword bar ───────────────────────────────────────────────────────── */
.keywordBar { .keywordBar {
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
flex-shrink: 0; flex-shrink: 0;
@@ -1137,7 +1071,6 @@
flex-direction: column; flex-direction: column;
gap: var(--sp-2); gap: var(--sp-2);
} }
.searchBar { .searchBar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1149,9 +1082,7 @@
transition: border-color var(--t-base); transition: border-color var(--t-base);
} }
.searchBar:focus-within { border-color: var(--border-strong); } .searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; } .searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { .searchInput {
flex: 1; flex: 1;
background: none; background: none;
@@ -1162,7 +1093,6 @@
padding: 7px 0; padding: 7px 0;
} }
.searchInput::placeholder { color: var(--text-faint); } .searchInput::placeholder { color: var(--text-faint); }
.clearBtn { .clearBtn {
color: var(--text-faint); color: var(--text-faint);
font-size: 14px; font-size: 14px;
@@ -1174,7 +1104,6 @@
transition: color var(--t-base); transition: color var(--t-base);
} }
.clearBtn:hover { color: var(--text-muted); } .clearBtn:hover { color: var(--text-muted); }
.advancedBtn { .advancedBtn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1192,7 +1121,6 @@
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } .advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); } .advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.searchBtn { .searchBtn {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1211,9 +1139,6 @@
} }
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } .searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
.searchBtn:disabled { opacity: 0.4; cursor: default; } .searchBtn:disabled { opacity: 0.4; cursor: default; }
/* ── Advanced filter panel ─────────────────────────────────────────────── */
.advancedPanel { .advancedPanel {
background: var(--bg-raised); background: var(--bg-raised);
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
@@ -1224,13 +1149,11 @@
gap: var(--sp-2); gap: var(--sp-2);
animation: fadeIn 0.1s ease both; animation: fadeIn 0.1s ease both;
} }
.advancedHeader { .advancedHeader {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.advancedTitle { .advancedTitle {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1238,9 +1161,7 @@
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
} }
.advancedActions { display: flex; gap: var(--sp-1); } .advancedActions { display: flex; gap: var(--sp-1); }
.advancedLink { .advancedLink {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1254,13 +1175,11 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.advancedLink:hover { opacity: 1; } .advancedLink:hover { opacity: 1; }
.langGrid { .langGrid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--sp-1); gap: var(--sp-1);
} }
.langChip { .langChip {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1274,20 +1193,17 @@
transition: color var(--t-base), border-color var(--t-base), background var(--t-base); transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
} }
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); } .langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
.langChipActive { .langChipActive {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--accent-dim); border-color: var(--accent-dim);
color: var(--accent-fg); color: var(--accent-fg);
} }
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } .langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.advancedDivider { .advancedDivider {
height: 1px; height: 1px;
background: var(--border-dim); background: var(--border-dim);
margin: 2px 0; margin: 2px 0;
} }
.advancedCheck { .advancedCheck {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1297,16 +1213,13 @@
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
} }
.checkbox { accent-color: var(--accent-fg); cursor: pointer; } .checkbox { accent-color: var(--accent-fg); cursor: pointer; }
.advancedFooter { .advancedFooter {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
} }
.advancedLinkStandalone { .advancedLinkStandalone {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1323,9 +1236,6 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.advancedLinkStandalone:hover { opacity: 1; } .advancedLinkStandalone:hover { opacity: 1; }
/* ── Empty states ──────────────────────────────────────────────────────── */
.empty { .empty {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1334,33 +1244,26 @@
justify-content: center; justify-content: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
.emptyIcon { color: var(--text-faint); } .emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); } .emptyText { font-size: var(--text-base); color: var(--text-muted); }
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } .emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
/* ── Keyword results ───────────────────────────────────────────────────── */
.results { .results {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sourceSection { .sourceSection {
padding: var(--sp-1) var(--sp-4) var(--sp-3); padding: var(--sp-1) var(--sp-4) var(--sp-3);
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
} }
.sourceSection:last-child { border-bottom: none; } .sourceSection:last-child { border-bottom: none; }
.sourceHeader { .sourceHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
.sourceIcon { .sourceIcon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1369,13 +1272,11 @@
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-raised); background: var(--bg-raised);
} }
.sourceName { .sourceName {
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
color: var(--text-secondary); color: var(--text-secondary);
} }
.sourceLang { .sourceLang {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1386,7 +1287,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 1px 5px; padding: 1px 5px;
} }
.resultCount { .resultCount {
margin-left: auto; margin-left: auto;
font-family: var(--font-ui); font-family: var(--font-ui);
@@ -1394,15 +1294,12 @@
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
} }
.sourceError { .sourceError {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-error); color: var(--color-error);
padding: var(--sp-1) 0; padding: var(--sp-1) 0;
margin: 0; margin: 0;
} }
/* Horizontal scroll row */
.sourceRow { .sourceRow {
display: flex; display: flex;
gap: var(--sp-3); gap: var(--sp-3);
@@ -1411,9 +1308,6 @@
scrollbar-width: none; scrollbar-width: none;
} }
.sourceRow::-webkit-scrollbar { display: none; } .sourceRow::-webkit-scrollbar { display: none; }
/* ── Manga card ────────────────────────────────────────────────────────── */
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1428,7 +1322,6 @@
} }
.card:hover .cover { filter: brightness(1.06); } .card:hover .cover { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); } .card:hover .cardTitle { color: var(--text-primary); }
.coverWrap { .coverWrap {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -1439,14 +1332,12 @@
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
transform: translateZ(0); transform: translateZ(0);
} }
.cover { .cover {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: filter var(--t-base); transition: filter var(--t-base);
} }
.inLibBadge { .inLibBadge {
position: absolute; position: absolute;
bottom: var(--sp-1); bottom: var(--sp-1);
@@ -1461,7 +1352,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--accent-muted); border: 1px solid var(--accent-muted);
} }
.cardTitle { .cardTitle {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
@@ -1472,9 +1362,6 @@
overflow: hidden; overflow: hidden;
transition: color var(--t-base); transition: color var(--t-base);
} }
/* ── Skeleton ──────────────────────────────────────────────────────────── */
.skCard { .skCard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1482,30 +1369,20 @@
flex-shrink: 0; flex-shrink: 0;
width: 110px; width: 110px;
} }
.tagGrid .card { width: 100%; } .tagGrid .card { width: 100%; }
.tagGrid .skCard { width: 100%; } .tagGrid .skCard { width: 100%; }
.skeleton { border-radius: var(--radius-sm); } .skeleton { border-radius: var(--radius-sm); }
.skCover { .skCover {
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
width: 100%; width: 100%;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.skTitle { height: 10px; width: 80%; } .skTitle { height: 10px; width: 80%; }
/* ── Split root (Tag + Source tabs) ────────────────────────────────────── */
.splitRoot { .splitRoot {
flex: 1; flex: 1;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
/* ── Split sidebar ─────────────────────────────────────────────────────── */
.splitSidebar { .splitSidebar {
width: 180px; width: 180px;
flex-shrink: 0; flex-shrink: 0;
@@ -1514,7 +1391,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.splitSearchWrap { .splitSearchWrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1523,9 +1399,7 @@
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-shrink: 0;
} }
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; } .splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput { .splitSearchInput {
flex: 1; flex: 1;
background: none; background: none;
@@ -1537,7 +1411,6 @@
min-width: 0; min-width: 0;
} }
.splitSearchInput::placeholder { color: var(--text-faint); } .splitSearchInput::placeholder { color: var(--text-faint); }
.splitSearchClear { .splitSearchClear {
color: var(--text-faint); color: var(--text-faint);
font-size: 13px; font-size: 13px;
@@ -1549,7 +1422,6 @@
transition: color var(--t-base); transition: color var(--t-base);
} }
.splitSearchClear:hover { color: var(--text-muted); } .splitSearchClear:hover { color: var(--text-muted); }
.splitList { .splitList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1557,7 +1429,6 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent; scrollbar-color: var(--border-dim) transparent;
} }
.splitItem { .splitItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1572,13 +1443,11 @@
transition: background var(--t-fast), border-color var(--t-fast); transition: background var(--t-fast), border-color var(--t-fast);
} }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); } .splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { .splitItemActive {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--accent-dim); border-color: var(--accent-dim);
} }
.splitItemActive:hover { background: var(--accent-muted); } .splitItemActive:hover { background: var(--accent-muted); }
.splitItemLabel { .splitItemLabel {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-muted); color: var(--text-muted);
@@ -1588,9 +1457,7 @@
white-space: nowrap; white-space: nowrap;
} }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); } .splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitItemSource { gap: var(--sp-2); } .splitItemSource { gap: var(--sp-2); }
.splitEmpty { .splitEmpty {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1598,7 +1465,6 @@
padding: var(--sp-3); padding: var(--sp-3);
margin: 0; margin: 0;
} }
.splitLoading { .splitLoading {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1606,16 +1472,12 @@
justify-content: center; justify-content: center;
padding: var(--sp-6); padding: var(--sp-6);
} }
/* ── Split content ─────────────────────────────────────────────────────── */
.splitContent { .splitContent {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.splitContentHeader { .splitContentHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1625,7 +1487,6 @@
flex-shrink: 0; flex-shrink: 0;
gap: var(--sp-2); gap: var(--sp-2);
} }
.splitSourceTitle { .splitSourceTitle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1633,7 +1494,6 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.splitContentTitle { .splitContentTitle {
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
@@ -1643,7 +1503,6 @@
white-space: nowrap; white-space: nowrap;
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.splitResultCount { .splitResultCount {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1651,7 +1510,6 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
flex-shrink: 0; flex-shrink: 0;
} }
.splitSourceIcon { .splitSourceIcon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1660,9 +1518,6 @@
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-raised); background: var(--bg-raised);
} }
/* ── Tag active bar ────────────────────────────────────────────────────── */
.tagActiveBar { .tagActiveBar {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1672,7 +1527,6 @@
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.tagPillRow { .tagPillRow {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1680,7 +1534,6 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.tagPill { .tagPill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1694,7 +1547,6 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
color: var(--accent-fg); color: var(--accent-fg);
} }
.tagPillRemove { .tagPillRemove {
color: var(--accent-fg); color: var(--accent-fg);
opacity: 0.6; opacity: 0.6;
@@ -1707,21 +1559,18 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.tagPillRemove:hover { opacity: 1; } .tagPillRemove:hover { opacity: 1; }
.tagBarRight { .tagBarRight {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
.tagModeToggle { .tagModeToggle {
display: flex; display: flex;
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
} }
.tagModeBtn { .tagModeBtn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1741,7 +1590,6 @@
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } .tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } .tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.tagClearAll { .tagClearAll {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1761,15 +1609,11 @@
border-color: color-mix(in srgb, var(--color-error) 40%, transparent); border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
} }
.tagCheckMark { .tagCheckMark {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--accent-fg); color: var(--accent-fg);
margin-left: auto; margin-left: auto;
} }
/* ── Grid results ──────────────────────────────────────────────────────── */
.tagGrid { .tagGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
@@ -1779,9 +1623,6 @@
flex: 1; flex: 1;
align-content: start; align-content: start;
} }
/* ── Show more / load more ─────────────────────────────────────────────── */
.showMoreCell { .showMoreCell {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@@ -1789,7 +1630,6 @@
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
.showMoreBtn { .showMoreBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1811,7 +1651,6 @@
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.showMoreBtn:disabled { opacity: 0.4; cursor: default; } .showMoreBtn:disabled { opacity: 0.4; cursor: default; }
.loadMoreRow { .loadMoreRow {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1819,9 +1658,6 @@
flex-shrink: 0; flex-shrink: 0;
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
} }
/* ── Source tab: lang filter + browse bar ──────────────────────────────── */
.sourceBrowseBar { .sourceBrowseBar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1830,9 +1666,54 @@
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-shrink: 0;
} }
.srcLangRow {
/* ── NSFW badge ────────────────────────────────────────────────────────── */ display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
gap: var(--sp-2);
}
.langPocketLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.langSelect {
appearance: none;
-webkit-appearance: none;
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 4px 24px 4px 8px;
cursor: pointer;
max-width: 110px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 7px center;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.langSelect:hover {
border-color: var(--border-strong);
background-color: var(--bg-raised);
color: var(--text-primary);
}
.langSelect:focus {
outline: none;
border-color: var(--accent-dim);
color: var(--text-primary);
}
.langSelect option {
background: var(--bg-surface);
color: var(--text-secondary);
}
.nsfwBadge { .nsfwBadge {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1845,81 +1726,8 @@
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Language pocket ───────────────────────────────────────────────────── */
.langPocketToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
border-top: none;
border-left: none;
border-right: none;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-fast);
}
.langPocketToggle:hover { background: var(--bg-raised); }
.langPocketLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.langPocket {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
animation: fadeIn 0.1s ease both;
}
/* ── Source group (multi-lang) ─────────────────────────────────────────── */
.splitItemGroup { } .splitItemGroup { }
.splitItemGroupOpen { background: var(--bg-raised); } .splitItemGroupOpen { background: var(--bg-raised); }
.groupLangCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
padding: 0px 5px;
flex-shrink: 0;
letter-spacing: var(--tracking-wide);
}
.groupChevron {
color: var(--text-faint);
flex-shrink: 0;
transition: transform 0.2s ease;
}
.splitItemLangOption {
padding-left: var(--sp-5);
background: var(--bg-overlay);
}
.splitItemLangOption:hover { background: var(--bg-raised); }
.langOptionDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--border-strong);
flex-shrink: 0;
}
.splitItemActive .langOptionDot { background: var(--accent-fg); }
</style> </style>
<script module> <script module>
+13 -4
View File
@@ -87,6 +87,15 @@
} }
return sortDir === "desc" ? base.reverse() : base; return sortDir === "desc" ? base.reverse() : base;
}); });
/**
* Chapter list in canonical reading order (ch1 -> ch2 -> ch3).
* Always passed to openReader so the Reader's idx-based prev/next
* navigation is direction-independent of the user's display sort.
*/
const chaptersAsc = $derived(
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
);
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE)); const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE)); const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length); const readCount = $derived(chapters.filter(c => c.isRead).length);
@@ -444,7 +453,7 @@
<!-- Zone 3: Primary CTA + library action --> <!-- Zone 3: Primary CTA + library action -->
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}> <button class="read-btn" onclick={() => openReader(continueChapter!.chapter, chaptersAsc)}>
<Play size={12} weight="fill" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` ? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
@@ -665,7 +674,7 @@
{#each sortedChapters as ch, i} {#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
onclick={() => openReader(ch, sortedChapters)} onclick={() => openReader(ch, chaptersAsc)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
@@ -677,8 +686,8 @@
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
onclick={() => openReader(ch, sortedChapters)} onclick={() => openReader(ch, chaptersAsc)}
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)} onkeydown={(e) => e.key === "Enter" && openReader(ch, chaptersAsc)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<div class="ch-left"> <div class="ch-left">
<span class="ch-name">{ch.name}</span> <span class="ch-name">{ch.name}</span>
+296 -53
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack, tick } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte"; import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, BookmarkSimple } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, resetChapterProgress } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import { setReading } from "../../lib/discord";
import type { FitMode } from "../../store/state.svelte"; import type { FitMode } from "../../store/state.svelte";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -12,6 +13,10 @@
const AVG_MIN_PER_PAGE = 0.33; const AVG_MIN_PER_PAGE = 0.33;
const MAX_CACHED = 10; const MAX_CACHED = 10;
const READ_LINE_PCT = 0.20; const READ_LINE_PCT = 0.20;
// Zoom step per Ctrl+Wheel tick or keyboard shortcut (5% of viewer width)
const ZOOM_STEP = 0.05;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4.0;
// ─── Page cache ─────────────────────────────────────────────────────────────── // ─── Page cache ───────────────────────────────────────────────────────────────
@@ -93,6 +98,47 @@
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
// ─── Container width (for resolution-based zoom) ──────────────────────────────
// Tracked via ResizeObserver so 100% zoom always means "fills the viewer",
// regardless of screen resolution or window size.
let containerWidth = $state(0);
// ─── Zoom anchor (longstrip) ──────────────────────────────────────────────────
// Before zoom changes the layout we snapshot which image is at the top of the
// viewport and how far it is from the top edge. After the DOM re-renders at
// the new zoom we scroll back so that same image is at the same visual offset,
// preventing the "random page teleport" that occurs when scrollHeight changes.
let zoomAnchorEl: HTMLElement | null = null;
let zoomAnchorOffset: number = 0;
function captureZoomAnchor() {
if (!containerEl || style !== "longstrip") return;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of imgs) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) {
zoomAnchorEl = img;
zoomAnchorOffset = rect.top - containerTop;
return;
}
}
}
function restoreZoomAnchor() {
if (!zoomAnchorEl || !containerEl) return;
const el = zoomAnchorEl;
zoomAnchorEl = null;
// Use rAF to wait for the DOM to finish re-laying out after the zoom change.
requestAnimationFrame(() => {
const containerTop = containerEl.getBoundingClientRect().top;
const newRect = el.getBoundingClientRect();
containerEl.scrollTop += (newRect.top - containerTop) - zoomAnchorOffset;
});
}
// ─── UI state ───────────────────────────────────────────────────────────────── // ─── UI state ─────────────────────────────────────────────────────────────────
let loading = $state(true); let loading = $state(true);
@@ -124,18 +170,68 @@
const rtl = $derived(store.settings.readingDirection === "rtl"); const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived(store.settings.pageStyle ?? "single"); const style = $derived(store.settings.pageStyle ?? "single");
const maxW = $derived(store.settings.maxPageWidth ?? 900); const zoom = $derived(store.settings.readerZoom ?? 1.0);
const autoNext = $derived(store.settings.autoNextChapter ?? false); const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true); const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false); const overlayBars = $derived(store.settings.overlayBars ?? false);
const lastPage = $derived(store.pageUrls.length); const lastPage = $derived(store.pageUrls.length);
// effectiveWidth: how wide the image should be, in pixels.
// = container width × zoom multiplier. Applied as max-width on the viewer
// so fit modes (height, screen) can still further constrain the image.
const effectiveWidth = $derived(
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
);
const zoomPct = $derived(Math.round(zoom * 100));
// ─── Resume / bookmark ────────────────────────────────────────────────────────
// resumePage: fixed at component init from history. Never changes after mount.
// We read from history directly (not store.pageNumber) because loadChapter
// temporarily resets store.pageNumber to 1 during the fetch.
const _resumeHistoryPage = store.activeChapter
? (store.history.find(h => h.chapterId === store.activeChapter!.id)?.pageNumber ?? 1)
: 1;
let resumePage = $state(_resumeHistoryPage > 1 ? _resumeHistoryPage : 0);
let resumeDismissed = $state(false);
// stripResumeReady: flipped to true once the longstrip scroll-to-resume fires.
// In single/double mode store.pageNumber drives the banner; in longstrip we
// use this flag because store.pageNumber is scroll-observer-driven and may
// never exactly equal resumePage after layout shifts from image loading.
let stripResumeReady = $state(false);
const showResumeBanner = $derived(
resumePage > 1 && !resumeDismissed &&
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
);
const currentBookmark = $derived(
store.activeChapter ? store.bookmarks.find(b => b.chapterId === store.activeChapter!.id) : undefined
);
const isBookmarked = $derived(!!currentBookmark);
// In longstrip, always track the visually active chapter for history/RPC —
// autoNext only controls nav-button behavior, not which chapter we attribute
// progress to. Without this, scrolling into ch48 while ch47 is activeChapter
// would record page 28 of ch48 as page 28 of ch47.
const displayChapter = $derived( const displayChapter = $derived(
style === "longstrip" && autoNext && visibleChapterId style === "longstrip" && visibleChapterId
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter) ? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
: store.activeChapter : store.activeChapter
); );
// ─── Discord RPC ──────────────────────────────────────────────────────────────
// displayChapter already handles both single/double (store.activeChapter) and
// longstrip auto-next (visibleChapterId) — so reacting to it here means RPC
// updates on every chapter transition regardless of reading mode.
$effect(() => {
const chapter = displayChapter;
const manga = store.activeManga;
if (store.settings.discordRpc && chapter && manga) {
setReading(manga, chapter);
}
});
const adjacent = $derived.by(() => { const adjacent = $derived.by(() => {
const ref = displayChapter ?? store.activeChapter; const ref = displayChapter ?? store.activeChapter;
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] }; if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
@@ -148,7 +244,7 @@
}); });
const visibleChunkLastPage = $derived.by(() => { const visibleChunkLastPage = $derived.by(() => {
if (style !== "longstrip" || !autoNext) return lastPage; if (style !== "longstrip") return lastPage;
const chId = visibleChapterId ?? store.activeChapter?.id; const chId = visibleChapterId ?? store.activeChapter?.id;
const chunk = stripChapters.find(c => c.chapterId === chId); const chunk = stripChapters.find(c => c.chapterId === chId);
return chunk?.urls.length ?? lastPage; return chunk?.urls.length ?? lastPage;
@@ -167,7 +263,7 @@
const stripToRender = $derived( const stripToRender = $derived(
style === "longstrip" style === "longstrip"
? (autoNext && stripChapters.length > 0 ? (stripChapters.length > 0
? stripChapters ? stripChapters
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }]) : [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
: [] : []
@@ -201,11 +297,18 @@
pageReady = false; pageReady = false;
stripChapters = []; stripChapters = [];
store.pageUrls = []; store.pageUrls = [];
// Snapshot the resume page BEFORE resetting — openReader already set
// store.pageNumber to the saved position, but we must not clobber it here.
// We reset to 1 as a safe interim value while pages load, then restore
// after the fetch completes so the viewer jumps to the right page.
const resumeTo = store.pageNumber > 1 ? store.pageNumber : 1;
store.pageNumber = 1; store.pageNumber = 1;
try { try {
const urls = await fetchPages(id, ctrl.signal); const urls = await fetchPages(id, ctrl.signal);
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
store.pageUrls = urls; store.pageUrls = urls;
// Clamp the resume page to actual page count (in case history is stale).
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
pageReady = true; pageReady = true;
loading = false; loading = false;
} catch (e: any) { } catch (e: any) {
@@ -216,31 +319,59 @@
} }
// ─── Strip initialisation ───────────────────────────────────────────────────── // ─── Strip initialisation ─────────────────────────────────────────────────────
// Runs when a chapter finishes loading in longstrip mode. // IMPORTANT: do NOT read store.pageNumber here — it's updated by the scroll
// Starts the strip with just the current chapter; appendNextChapter adds more // observer on every scroll event, which would re-run this effect continuously
// as the user scrolls. Nothing is ever removed from the DOM mid-read. // and reset stripChapters/scroll on every pixel scrolled (the "snap" bug).
// Resume page is read from the fixed `resumePage` $state instead, which is
// captured once at component init from history and never changes.
$effect(() => { $effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) { if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
const ch = store.activeChapter; const ch = store.activeChapter;
const urls = store.pageUrls; const urls = store.pageUrls;
// resumePage is a $state set once from history — not reactive to scroll.
const targetPg = untrack(() => resumePage);
appending = false; appending = false;
if (autoNext) { // Always populate stripChapters in longstrip — it's needed for infinite
// scroll appending. autoNext only controls whether the chapter header
// and visible-chapter tracking update as you scroll between chapters.
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }]; stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
visibleChapterId = ch.id; visibleChapterId = ch.id;
} else { // Wait for Svelte to flush the new img elements into the DOM, then scroll.
stripChapters = []; // If resuming mid-chapter (targetPg > 1), force-load preceding images so
visibleChapterId = null; // their heights are in layout, then scrollIntoView on the target image.
tick().then(() => {
if (!containerEl) return;
if (targetPg > 1) {
const chId = ch.id;
const scrollToResumePage = () => {
const target = containerEl.querySelector<HTMLImageElement>(
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
);
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
// Eager-load all images up to the target so their heights are known.
containerEl.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`)
.forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
const doScroll = () => {
target.scrollIntoView({ block: "start" });
stripResumeReady = true;
};
if (target.complete && target.naturalHeight > 0) { doScroll(); }
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
};
scrollToResumePage();
return;
} }
if (containerEl) containerEl.scrollTop = 0; containerEl.scrollTop = 0;
});
} }
}); });
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; }); $effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
// ─── Forward append only ────────────────────────────────────────────────────── // ─── Forward append only ──────────────────────────────────────────────────────
// Appends the next chapter to the bottom when the user scrolls past 80%.
// No eviction, no prepend, no sliding window — chapters accumulate forward.
function appendNextChapter() { function appendNextChapter() {
if (appending || !stripChapters.length) return; if (appending || !stripChapters.length) return;
@@ -271,9 +402,6 @@
let stripChaptersRef: StripChapter[] = []; let stripChaptersRef: StripChapter[] = [];
$effect(() => { stripChaptersRef = stripChapters; }); $effect(() => { stripChaptersRef = stripChapters; });
let autoNextRef = false;
$effect(() => { autoNextRef = autoNext; });
function setupScrollTracking(): () => void { function setupScrollTracking(): () => void {
if (!containerEl || style !== "longstrip") return () => {}; if (!containerEl || style !== "longstrip") return () => {};
@@ -299,7 +427,13 @@
} }
if (activePage !== null) store.pageNumber = activePage; if (activePage !== null) store.pageNumber = activePage;
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId; if (activeChId && activeChId !== visibleChapterId) {
// Crossed into a new chapter — reset the previous chapter's resume
// position to page 1 so reopening it starts fresh. The history entry
// itself is kept so it still appears in the continue-reading UI.
if (visibleChapterId) resetChapterProgress(visibleChapterId);
visibleChapterId = activeChId;
}
if (store.settings.autoMarkRead && activePage !== null && activeChId) { if (store.settings.autoMarkRead && activePage !== null && activeChId) {
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId); const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
@@ -314,7 +448,8 @@
} }
function onScrollAppend() { function onScrollAppend() {
if (!autoNextRef) return; // Infinite scroll always active in longstrip — autoNext only controls the
// nav-button chapter transition behavior, not scroll-triggered appending.
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) appendNextChapter(); if (pct >= 0.80) appendNextChapter();
} }
@@ -398,17 +533,8 @@
}); });
// ─── Progress / history tracking ───────────────────────────────────────────── // ─── Progress / history tracking ─────────────────────────────────────────────
// Only records history after the user has genuinely navigated (pageNumber > 1,
// or scrolled past page 1 in longstrip). This prevents the chapter-open event
// from writing "page 1" as the last-read position, which caused the history to
// always show the chapter you started on rather than where you left off.
$effect(() => { $effect(() => {
// Use displayChapter, not store.activeChapter — in longstrip with autoNext,
// store.activeChapter stays as the chapter you *opened* (e.g. ch61) while
// displayChapter tracks visibleChapterId (the chapter actually on screen).
// Using store.activeChapter here caused every history write to stamp ch61
// even when the user had scrolled all the way to ch72.
const ch = displayChapter ?? store.activeChapter; const ch = displayChapter ?? store.activeChapter;
if (ch && lastPage && store.activeManga) { if (ch && lastPage && store.activeManga) {
const chapterId = ch.id; const chapterId = ch.id;
@@ -419,11 +545,9 @@
const pageNum = store.pageNumber; const pageNum = store.pageNumber;
const atLast = store.pageNumber === lastPage; const atLast = store.pageNumber === lastPage;
// Mark that the user has moved past the initial load.
if (pageNum > 1) hasNavigated = true; if (pageNum > 1) hasNavigated = true;
untrack(() => { untrack(() => {
// Skip the very first page-1 write that fires on chapter load.
if (!hasNavigated) return; if (!hasNavigated) return;
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() }); addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId); if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
@@ -507,6 +631,42 @@
const goNext = $derived(rtl ? goBack : goForward); const goNext = $derived(rtl ? goBack : goForward);
const goPrev = $derived(rtl ? goForward : goBack); const goPrev = $derived(rtl ? goForward : goBack);
// ─── Zoom helpers ─────────────────────────────────────────────────────────────
function clampZoom(z: number): number {
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
}
function adjustZoom(delta: number) {
captureZoomAnchor();
updateSettings({ readerZoom: clampZoom(zoom + delta) });
restoreZoomAnchor();
}
function resetZoom() {
captureZoomAnchor();
updateSettings({ readerZoom: 1.0 });
restoreZoomAnchor();
}
function toggleBookmark() {
const ch = store.activeChapter;
const manga = store.activeManga;
if (!ch || !manga) return;
if (isBookmarked) {
removeBookmark(ch.id);
} else {
addBookmark({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: store.pageNumber,
});
}
}
// ─── Settings toggles ───────────────────────────────────────────────────────── // ─── Settings toggles ─────────────────────────────────────────────────────────
function cycleStyle() { function cycleStyle() {
@@ -531,13 +691,13 @@
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
e.preventDefault(); e.preventDefault();
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) }); // Each wheel tick adjusts by ZOOM_STEP (5%). Larger deltaY = bigger scroll = same step.
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS; const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const mW = store.settings.maxPageWidth ?? 900;
const r = store.settings.readingDirection === "rtl"; const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -545,9 +705,9 @@
if (dlOpen) { dlOpen = false; return; } if (dlOpen) { dlOpen = false; return; }
closeReader(); return; closeReader(); return;
} }
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; } if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; } if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
@@ -569,6 +729,7 @@
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); } else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); } else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); } else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
} }
function handleTap(e: MouseEvent) { function handleTap(e: MouseEvent) {
@@ -591,12 +752,20 @@
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
containerEl?.focus({ preventScroll: true }); containerEl?.focus({ preventScroll: true });
// Track the viewer's actual paint width so zoom is always relative to it.
const ro = new ResizeObserver(entries => {
containerWidth = entries[0].contentRect.width;
});
ro.observe(containerEl);
return () => { return () => {
abortCtrl?.abort(); abortCtrl?.abort();
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
cleanupScroll(); cleanupScroll();
ro.disconnect();
}; };
}); });
</script> </script>
@@ -625,16 +794,37 @@
{:else}<ArrowsOut size={14} weight="light" />{/if} {:else}<ArrowsOut size={14} weight="light" />{/if}
<span class="mode-label">{fitLabel}</span> <span class="mode-label">{fitLabel}</span>
</button> </button>
<!-- ── Zoom controls ────────────────────────────────────────────────────── -->
<div class="zoom-wrap"> <div class="zoom-wrap">
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button> <div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
</button>
<button class="zoom-pct-btn" onclick={() => zoomOpen = !zoomOpen} title="Click to adjust zoom">
{zoomPct}%
</button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
</button>
</div>
{#if zoomOpen} {#if zoomOpen}
<div class="zoom-popover"> <div class="zoom-popover">
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW} <div class="zoom-slider-row">
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} /> <input type="range" class="zoom-slider" min={10} max={400} step={5} value={zoomPct}
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button> oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
</div>
<div class="zoom-presets">
{#each [50, 75, 100, 125, 150, 200] as pct}
<button class="zoom-preset" class:active={zoomPct === pct}
onclick={() => { captureZoomAnchor(); updateSettings({ readerZoom: pct / 100 }); restoreZoomAnchor(); }}>{pct}%</button>
{/each}
</div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div> </div>
{/if} {/if}
</div> </div>
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}> <button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span> <ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
</button> </button>
@@ -666,13 +856,19 @@
bind:this={containerEl} bind:this={containerEl}
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
style="--max-page-width:{maxW}px" style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation" role="presentation"
tabindex="-1" tabindex="-1"
onclick={handleTap} onclick={handleTap}
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }} onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }} onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
> >
{#if showResumeBanner}
<div class="resume-banner" role="status">
<span>Resumed from page {resumePage}</span>
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
</div>
{/if}
{#if loading} {#if loading}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if} {/if}
@@ -761,6 +957,7 @@
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); } .icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.2; cursor: default; } .icon-btn:disabled { opacity: 0.2; cursor: default; }
.icon-btn.active { color: var(--accent-fg); }
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); } .ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); } .ch-sep { color: var(--text-faint); }
@@ -770,25 +967,51 @@
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } .mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.mode-label { text-transform: capitalize; } .mode-label { text-transform: capitalize; }
/* ── Zoom controls ───────────────────────────────────────────────────────── */
.zoom-wrap { position: relative; flex-shrink: 0; } .zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); } .zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); } .zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; } .zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } .zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } .zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; }
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); } .zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
.zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); }
.zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
/* ── Viewer ──────────────────────────────────────────────────────────────── */
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; } .viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; } /*
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; } * Fit modes — all constrain within --effective-width (the zoom-adjusted
* container width). effectiveWidth is set as a CSS variable on .viewer
* so every fit class automatically respects the current zoom level.
*
* fit-width : fills up to effectiveWidth, never wider
* fit-height : constrained to viewport height; never taller, never wider than effectiveWidth
* fit-screen : fits within both axes (contain); never wider than effectiveWidth
* fit-original : natural image size, no constraint
*/
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
.fit-original { max-width: none; width: auto; height: auto; } .fit-original { max-width: none; width: auto; height: auto; }
.strip-gap { margin-bottom: 8px; } .strip-gap { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; } .double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; } .page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; } .gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; } .gap-right { margin-left: 2px; }
@@ -811,5 +1034,25 @@
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.dl-step-btn:disabled { opacity: 0.25; cursor: default; } .dl-step-btn:disabled { opacity: 0.25; cursor: default; }
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); } .dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
/* ── Resume banner ───────────────────────────────────────────────────────── */
.resume-banner {
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
display: flex; align-items: center; gap: var(--sp-2);
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: 6px var(--sp-3);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); z-index: 20;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: scaleIn 0.15s ease both;
white-space: nowrap;
}
.resume-dismiss {
display: flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%;
font-size: 9px; color: var(--text-faint);
transition: color var(--t-fast), background var(--t-fast);
}
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -356,7 +356,7 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#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} <Play size={12} weight="fill" />{continueChapter.label}
</button> </button>
{/if} {/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
@@ -11,7 +12,7 @@
let search = $state(""); let search = $state("");
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
$effect(() => { onMount(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes; }) .then((d) => { sources = d.sources.nodes; })
.catch(console.error) .catch(console.error)
+79
View File
@@ -0,0 +1,79 @@
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
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://");
}
function resolveCoverImage(manga: Manga): string {
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE;
}
function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`;
}
function formatChapter(chapter: Chapter): string {
const n = chapter.chapterNumber;
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> {
sessionStart = Date.now();
await start(APP_ID).catch(() => {});
}
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
const assets = new Assets()
.setLargeImage(resolveCoverImage(manga))
.setLargeText(trunc(manga.title))
.setSmallImage(FALLBACK_IMAGE)
.setSmallText("Moku");
const activity = new Activity()
.setDetails(trunc(manga.title))
.setState(`${formatChapter(chapter)} · Reading`)
.setAssets(assets)
.setTimestamps(getTimestamps());
activity.setButton(BUTTONS);
await setActivity(activity).catch(() => {});
}
export async function setIdle(): Promise<void> {
const assets = new Assets()
.setLargeImage(FALLBACK_IMAGE)
.setLargeText("Moku");
const activity = new Activity()
.setDetails("Browsing")
.setAssets(assets)
.setTimestamps(getTimestamps());
activity.setButton(BUTTONS);
await setActivity(activity).catch(() => {});
}
export async function clearReading(): Promise<void> {
await clearActivity().catch(() => {});
}
export async function destroyRpc(): Promise<void> {
sessionStart = null;
await stop().catch(() => {});
}
+3
View File
@@ -12,6 +12,7 @@ export interface Keybinds {
togglePageStyle: string; togglePageStyle: string;
toggleFullscreen: string; toggleFullscreen: string;
openSettings: string; openSettings: string;
toggleBookmark: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
@@ -26,6 +27,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
togglePageStyle: "q", togglePageStyle: "q",
toggleFullscreen: "f", toggleFullscreen: "f",
openSettings: "o", openSettings: "o",
toggleBookmark: "m",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -40,6 +42,7 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
togglePageStyle: "Toggle page style", togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen", toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings", openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
}; };
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
+1
View File
@@ -17,6 +17,7 @@ export interface Manga {
inLibrary: boolean; inLibrary: boolean;
downloadCount?: number; downloadCount?: number;
unreadCount?: number; unreadCount?: number;
chapterCount?: number;
description?: string | null; description?: string | null;
status?: string | null; status?: string | null;
author?: string | null; author?: string | null;
+76
View File
@@ -5,6 +5,82 @@ export function cn(...inputs: ClassValue[]) {
return clsx(inputs); return clsx(inputs);
} }
// ── NSFW genre filtering ──────────────────────────────────────────────────────
/**
* 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.
*/
export const DEFAULT_NSFW_TAGS = [
"adult",
"mature",
"hentai",
"ecchi",
"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 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 },
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);
}
// ── Source deduplication ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
+130 -8
View File
@@ -8,6 +8,25 @@ export type NavPage = "home" | "library" | "sources" | "explore" | "dow
export type ReadingDirection = "ltr" | "rtl"; export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = "desc" | "asc";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type LibrarySortMode =
| "az"
| "unreadCount"
| "totalChapters"
| "recentlyAdded"
| "recentlyRead"
| "latestFetched"
| "latestUploaded";
export type LibrarySortDir = "asc" | "desc";
export type LibraryStatusFilter =
| "ALL"
| "ONGOING"
| "COMPLETED"
| "CANCELLED"
| "HIATUS"
| "UNKNOWN";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm"; export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123" export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
@@ -89,6 +108,18 @@ export interface HistoryEntry {
readAt: number; readAt: number;
} }
export interface BookmarkEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
savedAt: number;
/** Optional user label, e.g. "before the fight scene" */
label?: string;
}
/** /**
* ReadLogEntry append-only record of every chapter-completion event. * ReadLogEntry append-only record of every chapter-completion event.
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI), * Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
@@ -146,7 +177,12 @@ export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
maxPageWidth: number; /**
* 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; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean;
@@ -156,10 +192,16 @@ export interface Settings {
libraryCropCovers: boolean; libraryCropCovers: boolean;
libraryPageSize: number; libraryPageSize: number;
showNsfw: boolean; showNsfw: boolean;
discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; chapterPageSize: number;
uiScale: 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; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
serverUrl: string; serverUrl: string;
@@ -198,6 +240,23 @@ export interface Settings {
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */ /** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
/**
* Content filtering managed via the Content tab in Settings.
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive).
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true.
* nsfwBlockedSourceIds: sources always blocked regardless of tag content.
*/
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
// Legacy fields kept for migration reads only — never written after v3.
/** @deprecated use readerZoom */
maxPageWidth?: number;
/** @deprecated use uiZoom */
uiScale?: number;
} }
@@ -205,7 +264,7 @@ export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: "longstrip",
readingDirection: "ltr", readingDirection: "ltr",
fitMode: "width", fitMode: "width",
maxPageWidth: 900, readerZoom: 1.0,
pageGap: true, pageGap: true,
optimizeContrast: false, optimizeContrast: false,
offsetDoubleSpreads: false, offsetDoubleSpreads: false,
@@ -215,10 +274,11 @@ export const DEFAULT_SETTINGS: Settings = {
libraryCropCovers: true, libraryCropCovers: true,
libraryPageSize: 48, libraryPageSize: 48,
showNsfw: false, showNsfw: false,
discordRpc: false,
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
chapterPageSize: 25, chapterPageSize: 25,
uiScale: 100, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
@@ -256,16 +316,23 @@ export const DEFAULT_SETTINGS: Settings = {
customThemes: [], customThemes: [],
hiddenCategoryIds: [], hiddenCategoryIds: [],
defaultLibraryCategoryId: null, defaultLibraryCategoryId: null,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
libraryTabSort: {},
libraryTabStatus: {},
}; };
// ── Persistence ─────────────────────────────────────────────────────────────── // ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 2; const STORE_VERSION = 3;
// Fields reset to their DEFAULT_SETTINGS value on each version bump. // Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases. // Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom",
"uiZoom",
]; ];
function loadPersisted(): any { function loadPersisted(): any {
@@ -314,6 +381,11 @@ function mergeSettings(saved: any): Settings {
mangaLinks: saved?.settings?.mangaLinks ?? {}, mangaLinks: saved?.settings?.mangaLinks ?? {},
customThemes: saved?.settings?.customThemes ?? [], customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
}; };
} }
@@ -340,6 +412,11 @@ class Store {
* Capped at 5 000 entries; oldest are trimmed first. * Capped at 5 000 entries; oldest are trimmed first.
*/ */
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []); readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
/**
* bookmarks user-placed markers at a specific page in a chapter.
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
*/
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
readingStats: ReadingStats = $state(mergeStats(saved)); readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
@@ -385,16 +462,26 @@ class Store {
$effect(() => { persist({ libraryFilter: this.libraryFilter }); }); $effect(() => { persist({ libraryFilter: this.libraryFilter }); });
$effect(() => { persist({ history: this.history }); }); $effect(() => { persist({ history: this.history }); });
$effect(() => { persist({ readLog: this.readLog }); }); $effect(() => { persist({ readLog: this.readLog }); });
$effect(() => { persist({ bookmarks: this.bookmarks }); });
$effect(() => { persist({ readingStats: this.readingStats }); }); $effect(() => { persist({ readingStats: this.readingStats }); });
$effect(() => { persist({ settings: this.settings }); }); $effect(() => { persist({ settings: this.settings }); });
}); });
} }
openReader(chapter: Chapter, chapterList: Chapter[]) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
// Always set activeManga when provided so the Reader has full manga
// context for Discord RPC (setReading) and any other manga-aware logic.
// Callers that already set store.activeManga directly may omit this arg.
if (manga) this.activeManga = manga;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; this.pageUrls = [];
this.pageNumber = 1; // Resume from the last saved position if history has one for this chapter.
// history[n].pageNumber is kept up-to-date by the progress $effect in
// Reader.svelte as the user pages through, so this is always the last page
// they were on — not just the page they started from.
const saved = this.history.find(h => h.chapterId === chapter.id);
this.pageNumber = (saved && saved.pageNumber > 1) ? saved.pageNumber : 1;
} }
closeReader() { closeReader() {
@@ -472,7 +559,38 @@ class Store {
}; };
} }
/**
* Add or update a bookmark for the given chapter/page. Only one bookmark
* per chapter is kept adding a second one replaces the first.
*/
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [
bookmark,
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
].slice(0, 200);
}
removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
}
getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId);
}
clearHistory() { this.history = []; this.readLog = []; } clearHistory() { this.history = []; this.readLog = []; }
/**
* Reset the resume position for a chapter back to page 1.
* Called when the user scrolls past a chapter boundary in longstrip the
* chapter still appears in history (for the continue-reading UI), but
* reopening it will start from page 1 instead of resuming mid-chapter.
*/
resetChapterProgress(chapterId: number) {
this.history = this.history.map(h =>
h.chapterId === chapterId ? { ...h, pageNumber: 1 } : h
);
}
clearHistoryForManga(mangaId: number) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId); this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
@@ -604,10 +722,11 @@ export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ────────────────── // ── 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 closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); } export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
export function resetChapterProgress(chapterId: number) { store.resetChapterProgress(chapterId); }
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); } export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
export function wipeAllData() { store.wipeAllData(); } export function wipeAllData() { store.wipeAllData(); }
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); } export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
@@ -632,6 +751,9 @@ export function setSettingsOpen(next: boolean) { store
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function clearDiscoverCache() { store.clearDiscoverCache(); } export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); } export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }