mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 |
+1
-1
@@ -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: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -4201,14 +4201,14 @@
|
|||||||
{
|
{
|
||||||
"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/rustc-hash/rustc-hash-2.1.2.crate",
|
||||||
"sha256": "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d",
|
"sha256": "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe",
|
||||||
"dest": "cargo/vendor/rustc-hash-2.1.1"
|
"dest": "cargo/vendor/rustc-hash-2.1.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\", \"files\": {}}",
|
"contents": "{\"package\": \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rustc-hash-2.1.1",
|
"dest": "cargo/vendor/rustc-hash-2.1.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7503,27 +7503,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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+12
@@ -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
|
||||||
|
|||||||
Generated
+46
-7
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-79
@@ -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.25–1.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,22 +248,7 @@ struct ServerInvocation {
|
|||||||
working_dir: Option<PathBuf>,
|
working_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] checking path: {:?}", java));
|
|
||||||
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
|
||||||
|
|
||||||
if java.exists() { Some(java) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
eprintln!("{}", msg);
|
|
||||||
if let Some(f) = log {
|
if let Some(f) = log {
|
||||||
let _ = writeln!(f, "{}", msg);
|
let _ = writeln!(f, "{}", msg);
|
||||||
}
|
}
|
||||||
@@ -276,81 +261,50 @@ 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
|
||||||
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()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Err(SpawnError::NotConfigured(
|
||||||
let resource_dir = match app.path().resource_dir() {
|
format!("Configured binary not found: {}", path.display()),
|
||||||
Ok(p) => {
|
));
|
||||||
let stripped = strip_unc(p);
|
|
||||||
do_log(log, &format!("[resolve] resource_dir (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 sidecar (Windows / Linux AppImage)
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
||||||
|
for name in &candidates {
|
||||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
let p = resource_dir.join(name);
|
||||||
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
|
||||||
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
if p.exists() {
|
||||||
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
|
||||||
|
|
||||||
match find_java_in_bundle(&bundle_dir, log) {
|
|
||||||
Some(java) => {
|
|
||||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
|
||||||
if jar.exists() {
|
|
||||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: java.to_string_lossy().into_owned(),
|
bin: p.to_string_lossy().into_owned(),
|
||||||
args: vec![
|
args: vec![],
|
||||||
"-jar".to_string(),
|
working_dir: Some(resource_dir),
|
||||||
jar.to_string_lossy().into_owned(),
|
|
||||||
],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. macOS app bundle — look in MacOS/ and 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.parent()
|
||||||
// from resource_dir (Contents/Resources → Contents/MacOS).
|
.map(|p| p.join("MacOS"))
|
||||||
let macos_dir = resource_dir.join("../MacOS")
|
.unwrap_or_default();
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
||||||
|
|
||||||
// Tauri strips the target triple when installing externalBin sidecars
|
|
||||||
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
|
|
||||||
// at runtime. The triple-suffixed names are only needed on disk at
|
|
||||||
// build time for Tauri to pick the right arch during bundling.
|
|
||||||
let candidates = [
|
|
||||||
"suwayomi-server",
|
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Search MacOS/ first (correct location), then Resources/ as fallback
|
// Search MacOS/ first (correct location), then Resources/ as fallback
|
||||||
// for flat dev layouts where the script sits next to resources.
|
// for flat dev layouts where the script sits next to resources.
|
||||||
@@ -573,6 +527,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,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",
|
||||||
|
|||||||
+49
-7
@@ -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,23 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
|
// The OS/monitor DPI scale factor for the current display.
|
||||||
|
// Queried from Rust (window.scale_factor()) on mount and updated live
|
||||||
|
// whenever the window moves to a different monitor via the scaleChanged event.
|
||||||
|
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.25–1.5 = Windows scaled display.
|
||||||
|
let platformScale = $state(1.0);
|
||||||
|
|
||||||
|
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
|
||||||
|
// Applied to document.documentElement so the entire UI scales correctly.
|
||||||
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));
|
||||||
|
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
let prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -125,8 +136,9 @@
|
|||||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-apply zoom whenever uiZoom setting or platformScale changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
store.settings.uiScale; platformScale;
|
store.settings.uiZoom; platformScale;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,14 +226,25 @@
|
|||||||
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);
|
// Fetch the real monitor scale factor from Rust (window.scale_factor()).
|
||||||
|
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
|
||||||
|
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||||
applyZoom();
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-query the scale factor when the window moves to a different monitor.
|
||||||
|
// Tauri emits this event whenever the DPI changes (e.g. dragging window
|
||||||
|
// from a 1080p display to a 4K display).
|
||||||
|
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||||
|
platformScale = event.payload.scaleFactor;
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
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 +263,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 +279,23 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, isNsfwManga } 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 (!store.settings.showNsfw && isNsfwManga(m)) 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 => store.settings.showNsfw || !isNsfwManga(m))
|
||||||
|
);
|
||||||
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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -219,7 +219,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 +415,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 +582,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); }
|
||||||
|
|||||||
@@ -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, isNsfwManga } 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: "A–Z",
|
||||||
|
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,77 @@
|
|||||||
|
|
||||||
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;
|
if (!store.settings.showNsfw) {
|
||||||
|
items = items.filter(m => !isNsfwManga(m));
|
||||||
}
|
}
|
||||||
const id = Number(store.libraryFilter);
|
|
||||||
const items = categoryMangaMap.get(id) ?? [];
|
// 3. Text search
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sort
|
||||||
|
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 +585,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 +696,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 +895,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 +918,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 +962,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); }
|
||||||
|
|||||||
@@ -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, isNsfwManga } 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";
|
||||||
|
|
||||||
@@ -146,8 +146,11 @@
|
|||||||
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 = store.settings.showNsfw
|
||||||
|
? d.fetchSourceManga.mangas
|
||||||
|
: d.fetchSourceManga.mangas.filter((m) => !isNsfwManga(m));
|
||||||
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;
|
||||||
@@ -243,7 +246,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) => store.settings.showNsfw || !isNsfwManga(m);
|
||||||
|
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 +283,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) => store.settings.showNsfw || !isNsfwManga(m));
|
||||||
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 +309,8 @@
|
|||||||
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) => store.settings.showNsfw || !isNsfwManga(m);
|
||||||
|
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
|
||||||
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 +346,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) => store.settings.showNsfw || !isNsfwManga(m));
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -422,7 +429,10 @@
|
|||||||
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 = store.settings.showNsfw
|
||||||
|
? d.fetchSourceManga.mangas
|
||||||
|
: d.fetchSourceManga.mangas.filter((m) => !isNsfwManga(m));
|
||||||
|
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) {
|
||||||
@@ -1113,7 +1123,7 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } 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 } 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 } 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,40 @@
|
|||||||
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));
|
||||||
|
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && autoNext && visibleChapterId
|
style === "longstrip" && autoNext && 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: [] };
|
||||||
@@ -216,9 +284,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
||||||
// Runs when a chapter finishes loading in longstrip mode.
|
|
||||||
// Starts the strip with just the current chapter; appendNextChapter adds more
|
|
||||||
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||||
@@ -239,8 +304,6 @@
|
|||||||
$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;
|
||||||
@@ -398,17 +461,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 +473,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 +559,24 @@
|
|||||||
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();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function cycleStyle() {
|
function cycleStyle() {
|
||||||
@@ -531,13 +601,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 +615,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(); }
|
||||||
@@ -591,12 +661,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 +703,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,7 +765,7 @@
|
|||||||
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}
|
||||||
@@ -770,25 +869,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; }
|
||||||
|
|||||||
@@ -717,28 +717,30 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Interface Scale</p>
|
<p class="section-title">Interface Scale</p>
|
||||||
<div class="scale-row">
|
<div class="scale-row">
|
||||||
<input type="range" min={50} max={200} step={5} value={store.settings.uiScale}
|
<input type="range" min={50} max={200} step={5}
|
||||||
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
|
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
|
||||||
|
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
|
||||||
|
class="scale-slider" />
|
||||||
<input
|
<input
|
||||||
type="number" min={50} max={200} step={1}
|
type="number" min={50} max={200} step={1}
|
||||||
class="scale-val-input"
|
class="scale-val-input"
|
||||||
value={store.settings.uiScale}
|
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const n = parseInt(e.currentTarget.value, 10);
|
const n = parseInt(e.currentTarget.value, 10);
|
||||||
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n });
|
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 });
|
||||||
}}
|
}}
|
||||||
onblur={(e) => {
|
onblur={(e) => {
|
||||||
const n = parseInt(e.currentTarget.value, 10);
|
const n = parseInt(e.currentTarget.value, 10);
|
||||||
if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; }
|
if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; }
|
||||||
else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; }
|
else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="scale-pct">%</span>
|
<span class="scale-pct">%</span>
|
||||||
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset">↺</button>
|
<button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.5 })} disabled={(store.settings.uiZoom ?? 1.5) === 1.5} title="Reset">↺</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="scale-hint">
|
<p class="scale-hint">
|
||||||
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
|
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
|
||||||
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
|
<button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.5) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -773,6 +775,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<p class="section-title">Integrations</p>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<div class="toggle-info"><span class="toggle-label">Discord Rich Presence</span><span class="toggle-desc">Show what you're reading in your Discord status</span></div>
|
||||||
|
<button role="switch" aria-checked={store.settings.discordRpc} aria-label="Discord Rich Presence" class="toggle" class:on={store.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !store.settings.discordRpc })}><span class="toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -931,13 +940,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
|
<div class="toggle-info">
|
||||||
<div class="step-controls">
|
<span class="toggle-label">Default zoom</span>
|
||||||
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
<span class="toggle-desc">Starting zoom when opening a chapter. 100% = fills the reader.</span>
|
||||||
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span>
|
</div>
|
||||||
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
<div class="scale-row">
|
||||||
|
<input type="range" min={10} max={400} step={5}
|
||||||
|
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
|
||||||
|
oninput={(e) => updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })}
|
||||||
|
class="scale-slider" />
|
||||||
|
<input
|
||||||
|
type="number" min={10} max={400} step={5}
|
||||||
|
class="scale-val-input"
|
||||||
|
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
|
||||||
|
oninput={(e) => {
|
||||||
|
const n = parseInt(e.currentTarget.value, 10);
|
||||||
|
if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 });
|
||||||
|
}}
|
||||||
|
onblur={(e) => {
|
||||||
|
const n = parseInt(e.currentTarget.value, 10);
|
||||||
|
if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; }
|
||||||
|
else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="scale-pct">%</span>
|
||||||
|
<button class="step-btn" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%">↺</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="scale-hint">
|
||||||
|
{#each [50, 75, 100, 125, 150, 200] as v}
|
||||||
|
<button class="scale-preset"
|
||||||
|
class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v}
|
||||||
|
onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
||||||
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
||||||
@@ -1749,7 +1785,7 @@
|
|||||||
<p class="section-title">Links</p>
|
<p class="section-title">Links</p>
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||||
<a href="https://discord.gg/cfncTbJ2" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a>
|
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTONS = [
|
||||||
|
new Button("GitHub", "https://github.com/Youwes09/Moku"),
|
||||||
|
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function initRpc(): Promise<void> {
|
||||||
|
await start(APP_ID)
|
||||||
|
.then(() => console.log("[discord] RPC started"))
|
||||||
|
.catch((e) => console.error("[discord] initRpc failed:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(new Timestamps(Date.now()));
|
||||||
|
activity.setButton(BUTTONS);
|
||||||
|
|
||||||
|
await setActivity(activity)
|
||||||
|
.then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter)))
|
||||||
|
.catch((e) => console.error("[discord] setActivity failed:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIdle(): Promise<void> {
|
||||||
|
const assets = new Assets()
|
||||||
|
.setLargeImage(FALLBACK_IMAGE)
|
||||||
|
.setLargeText("Moku");
|
||||||
|
|
||||||
|
const activity = new Activity()
|
||||||
|
.setDetails("Browsing")
|
||||||
|
.setAssets(assets)
|
||||||
|
.setTimestamps(new Timestamps(Date.now()));
|
||||||
|
activity.setButton(BUTTONS);
|
||||||
|
|
||||||
|
await setActivity(activity)
|
||||||
|
.then(() => console.log("[discord] idle"))
|
||||||
|
.catch((e) => console.error("[discord] setActivity failed (idle):", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearReading(): Promise<void> {
|
||||||
|
await clearActivity()
|
||||||
|
.then(() => console.log("[discord] activity cleared"))
|
||||||
|
.catch((e) => console.error("[discord] clearActivity failed:", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyRpc(): Promise<void> {
|
||||||
|
await stop()
|
||||||
|
.then(() => console.log("[discord] RPC stopped"))
|
||||||
|
.catch((e) => console.error("[discord] destroyRpc failed:", e));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -5,6 +5,34 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return clsx(inputs);
|
return clsx(inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genre tags that indicate adult/mature content.
|
||||||
|
* Checked case-insensitively against each manga's genre array.
|
||||||
|
* Extend this set if additional tags need to be covered.
|
||||||
|
*/
|
||||||
|
const NSFW_GENRE_TAGS = new Set([
|
||||||
|
"adult",
|
||||||
|
"mature",
|
||||||
|
"hentai",
|
||||||
|
"ecchi",
|
||||||
|
"erotica",
|
||||||
|
"pornographic",
|
||||||
|
"18+",
|
||||||
|
"smut",
|
||||||
|
"lemon",
|
||||||
|
"explicit",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the manga carries at least one genre tag that is considered
|
||||||
|
* adult/mature. Used to enforce the `showNsfw` setting across all views.
|
||||||
|
*/
|
||||||
|
export function isNsfwManga(manga: { genre?: string[] | null }): boolean {
|
||||||
|
return (manga.genre ?? []).some((g) => NSFW_GENRE_TAGS.has(g.toLowerCase().trim()));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -146,7 +165,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 +180,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 +228,14 @@ 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;
|
||||||
|
/** 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 +243,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 +253,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 +295,20 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
customThemes: [],
|
customThemes: [],
|
||||||
hiddenCategoryIds: [],
|
hiddenCategoryIds: [],
|
||||||
defaultLibraryCategoryId: null,
|
defaultLibraryCategoryId: null,
|
||||||
|
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 +357,8 @@ 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 ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user