mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 |
@@ -1,21 +1,18 @@
|
|||||||
Major Revisions:
|
Major Revisions:
|
||||||
- Moku + Crossplatform Support (MacOS Remaining)
|
|
||||||
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config)
|
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
- Adjustment in Settings for Theme Editor:
|
|
||||||
- Allow User to Edit/Create Themes
|
|
||||||
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
|
||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||||
- Integrate Download Directory Changes (Settings)
|
- Integrate Download Directory Changes (Settings)
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||||
- MacOS Full-Screen & UI Compatability (TitleBar)
|
- Investigate Zoom (Reader), Appears to have Cutoff, etc.
|
||||||
|
|
||||||
General/Misc Bugs:
|
General/Misc Bugs:
|
||||||
- Fix Highlightable Elements
|
- Fix Highlightable Elements
|
||||||
@@ -25,9 +22,12 @@ General/Misc Bugs:
|
|||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
|
|
||||||
In-Progress:`
|
In-Progress:`
|
||||||
- Fix Reader Chapter Shifts (Glitched Sentinel)
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
- Still Shifts Down after reading ~8+ Chapters?
|
- Fix NSFW Parsing (Appears to not Work???)
|
||||||
- Identify When Chapters are Unloaded, How to Preserve Structure
|
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Patch Color-Picker to Work Properly
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
Important Commands:
|
||||||
|
|||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f
|
sha256: 9e9590cf8c98b07ca774382491b1d8cfcc1f2151afadbf8e23e2abda0c086c11
|
||||||
- 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.0";
|
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";
|
||||||
@@ -149,7 +149,7 @@ EOF
|
|||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
bumpScript = pkgs.writeShellApplication {
|
||||||
name = "moku-bump";
|
name = "moku-bump";
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||||
text = ''
|
text = ''
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
@@ -160,6 +160,7 @@ EOF
|
|||||||
"$REPO/src-tauri/Cargo.toml"
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
"$REPO/flake.nix"
|
"$REPO/flake.nix"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
echo "Bumped to $VERSION"
|
echo "Bumped to $VERSION"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+672
-360
File diff suppressed because it is too large
Load Diff
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
+53
-14
@@ -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.0"
|
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",
|
||||||
@@ -3347,10 +3348,23 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rpcdiscord"
|
||||||
version = "2.1.1"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
checksum = "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"uuid 0.8.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
@@ -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"
|
||||||
@@ -6140,18 +6179,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.47"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.47"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.5.0"
|
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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-45
@@ -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,10 +248,16 @@ struct ServerInvocation {
|
|||||||
working_dir: Option<PathBuf>,
|
working_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
|
||||||
|
///
|
||||||
|
/// Expected layout (Windows and Linux):
|
||||||
|
/// <bundle_dir>/jre/bin/java[.exe]
|
||||||
|
///
|
||||||
|
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
@@ -276,28 +282,35 @@ fn resolve_server_binary(
|
|||||||
) -> Result<ServerInvocation, SpawnError> {
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
||||||
|
|
||||||
|
// ── 1. User-specified binary path ─────────────────────────────────────────
|
||||||
|
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
|
||||||
|
// Fallback: if the path doesn't exist after stripping UNC, log a warning
|
||||||
|
// and continue so the bundled detection still has a chance.
|
||||||
if !binary.trim().is_empty() {
|
if !binary.trim().is_empty() {
|
||||||
do_log(log, "[resolve] using user-supplied binary path");
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
return Ok(ServerInvocation {
|
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||||
bin: binary.to_string(),
|
if path.exists() {
|
||||||
args: vec![],
|
return Ok(ServerInvocation {
|
||||||
working_dir: None,
|
bin: path.to_string_lossy().into_owned(),
|
||||||
});
|
args: vec![],
|
||||||
|
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fallback: path was set but file is missing — warn and keep trying.
|
||||||
|
do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
|
||||||
}
|
}
|
||||||
|
|
||||||
let resource_dir = match app.path().resource_dir() {
|
// Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
|
||||||
Ok(p) => {
|
#[cfg(not(target_os = "macos"))]
|
||||||
let stripped = strip_unc(p);
|
let resource_dir = {
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
stripped
|
let stripped = strip_unc(raw);
|
||||||
}
|
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
||||||
Err(e) => {
|
stripped
|
||||||
let msg = format!("resource_dir error: {e}");
|
|
||||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
|
||||||
return Err(SpawnError::SpawnFailed(msg));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
|
||||||
|
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
@@ -314,46 +327,92 @@ fn resolve_server_binary(
|
|||||||
if jar.exists() {
|
if jar.exists() {
|
||||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: java.to_string_lossy().into_owned(),
|
bin: java.to_string_lossy().into_owned(),
|
||||||
args: vec![
|
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
"-jar".to_string(),
|
|
||||||
jar.to_string_lossy().into_owned(),
|
|
||||||
],
|
|
||||||
working_dir: Some(bundle_dir),
|
working_dir: Some(bundle_dir),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
|
||||||
}
|
}
|
||||||
|
do_log(log, "[resolve] java found but jar MISSING — falling through");
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
do_log(log, "[resolve] java NOT found in bundle — falling through");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
|
||||||
|
// Fallback for older bundle layouts that ship a wrapper script instead of a
|
||||||
|
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
// Named launcher scripts.
|
||||||
|
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
|
||||||
|
for name in &script_candidates {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic JRE at resource_dir root + any *.jar alongside it.
|
||||||
|
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| {
|
||||||
|
rd.find(|e| {
|
||||||
|
e.as_ref()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
|
||||||
|
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(resource_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
// Tauri places externalBin sidecars next to the main binary in
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
// Contents/MacOS/, not in Contents/Resources/. Derive that path
|
let macos_dir = resource_dir
|
||||||
// from resource_dir (Contents/Resources → Contents/MacOS).
|
.parent()
|
||||||
let macos_dir = resource_dir.join("../MacOS")
|
.map(|p| p.join("MacOS"))
|
||||||
.canonicalize()
|
.unwrap_or_default();
|
||||||
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
|
||||||
|
|
||||||
// Tauri strips the target triple when installing externalBin sidecars
|
// Tauri strips the target triple when installing externalBin sidecars into
|
||||||
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
|
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
|
||||||
// at runtime. The triple-suffixed names are only needed on disk at
|
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
|
||||||
// build time for Tauri to pick the right arch during bundling.
|
// dev / flat layouts.
|
||||||
let candidates = [
|
let candidates = [
|
||||||
"suwayomi-server",
|
"suwayomi-server",
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Search MacOS/ first (correct location), then Resources/ as fallback
|
|
||||||
// for flat dev layouts where the script sits next to resources.
|
|
||||||
for search_dir in &[&macos_dir, &resource_dir] {
|
for search_dir in &[&macos_dir, &resource_dir] {
|
||||||
for name in &candidates {
|
for name in &candidates {
|
||||||
let p = search_dir.join(name);
|
let p = search_dir.join(name);
|
||||||
@@ -370,8 +429,18 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 4. PATH fallback ──────────────────────────────────────────────────────
|
||||||
|
// Use `where` on Windows, `which` everywhere else.
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
do_log(log, "[resolve] trying PATH fallback");
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let found = std::process::Command::new("where")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let found = std::process::Command::new("which")
|
let found = std::process::Command::new("which")
|
||||||
.arg(name)
|
.arg(name)
|
||||||
.output()
|
.output()
|
||||||
@@ -573,6 +642,7 @@ fn restart_app(app: tauri::AppHandle) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_drpc::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+35
-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,16 @@
|
|||||||
let notConfigured = $state(false);
|
let notConfigured = $state(false);
|
||||||
let idle = $state(false);
|
let idle = $state(false);
|
||||||
let devSplash = $state(false);
|
let devSplash = $state(false);
|
||||||
let platformScale = $state(1);
|
|
||||||
|
let platformScale = $state(1.0);
|
||||||
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const normalized = store.settings.uiScale * platformScale;
|
const uiZoom = store.settings.uiZoom ?? 1.5;
|
||||||
document.documentElement.style.zoom = `${normalized}%`;
|
const effective = platformScale * uiZoom;
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
const pct = effective * 100;
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
document.documentElement.style.zoom = `${pct}%`;
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(effective));
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
let prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -126,7 +130,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
store.settings.uiScale; platformScale;
|
store.settings.uiZoom; platformScale;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,14 +218,20 @@
|
|||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||||
applyZoom();
|
applyZoom();
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
store.isFullscreen = await win.isFullscreen();
|
||||||
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
const unlistenResize = await win.onResized(async () => {
|
||||||
store.isFullscreen = await win.isFullscreen();
|
store.isFullscreen = await win.isFullscreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlistenScale = await win.onScaleChanged(async (event) => {
|
||||||
|
platformScale = event.payload.scaleFactor;
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
if (store.settings.autoStartServer) {
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||||
if (err?.kind === "NotConfigured") {
|
if (err?.kind === "NotConfigured") {
|
||||||
@@ -240,6 +250,8 @@
|
|||||||
return () => {
|
return () => {
|
||||||
cancelProbe = true;
|
cancelProbe = true;
|
||||||
unlistenResize();
|
unlistenResize();
|
||||||
|
unlistenScale();
|
||||||
|
destroyRpc();
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
@@ -254,6 +266,22 @@
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store.settings.discordRpc) {
|
||||||
|
initRpc();
|
||||||
|
} else {
|
||||||
|
clearReading();
|
||||||
|
destroyRpc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the reader closes, show idle presence.
|
||||||
|
$effect(() => {
|
||||||
|
if (!store.activeChapter) {
|
||||||
|
if (store.settings.discordRpc) setIdle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleRetry() {
|
function handleRetry() {
|
||||||
failed = false;
|
failed = false;
|
||||||
notConfigured = false;
|
notConfigured = false;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||||
@@ -48,6 +48,8 @@
|
|||||||
|
|
||||||
let activeCtrl: AbortController | null = null;
|
let activeCtrl: AbortController | null = null;
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||||
@@ -58,7 +60,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterOut(mangas: Manga[]): Manga[] {
|
function filterOut(mangas: Manga[]): Manga[] {
|
||||||
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id)));
|
return dedup(mangas.filter(m => {
|
||||||
|
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
||||||
|
if (shouldHideNsfw(m, store.settings)) return false;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotatedSources(): Source[] {
|
function rotatedSources(): Source[] {
|
||||||
@@ -181,7 +187,9 @@
|
|||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
const local = dedup(d.mangas.nodes);
|
const local = dedup(
|
||||||
|
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
|
||||||
|
);
|
||||||
store.discoverCache.set(localKey, local);
|
store.discoverCache.set(localKey, local);
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
@@ -253,6 +261,12 @@
|
|||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
@@ -266,20 +280,26 @@
|
|||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
||||||
}).catch(console.error),
|
}).catch(console.error),
|
||||||
},
|
},
|
||||||
...(store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...store.settings.folders.map(f => ({
|
...categories.map(cat => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "New folder & add", icon: FolderSimplePlus,
|
label: "New folder & add", icon: FolderSimplePlus,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
const n = prompt("Folder name:");
|
const n = prompt("Folder name:");
|
||||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
if (!n?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let visibleCount = $state(PAGE_SIZE);
|
let visibleCount = $state(PAGE_SIZE);
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
const nextPageMap = new Map<string, number>();
|
const nextPageMap = new Map<string, number>();
|
||||||
let sources: Source[] = $state([]);
|
let sources: Source[] = $state([]);
|
||||||
@@ -143,19 +145,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||||
...(store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...store.settings.folders.map((f): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(
|
||||||
|
CREATE_CATEGORY,
|
||||||
|
{ name: name.trim() }
|
||||||
|
).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = (res as any).createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
|
}},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +215,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each visibleItems as m (m.id)}
|
{#each visibleItems as m (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } 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, COMPLETED_FOLDER_ID, 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 } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
@@ -30,20 +30,31 @@
|
|||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
function focusEl(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
let extraManga: Manga[] = $state([]);
|
let extraManga: Manga[] = $state([]);
|
||||||
let loadingLibrary: boolean = $state(true);
|
let loadingLibrary: boolean = $state(true);
|
||||||
|
let completedCategory: Category | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadLibrary() {
|
function loadLibrary() {
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
);
|
||||||
.catch(console.error)
|
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
.finally(() => loadingLibrary = false);
|
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
Promise.all([libraryP, categoriesP])
|
||||||
|
.then(([m, completed]) => {
|
||||||
|
libraryManga = m;
|
||||||
|
completedCategory = completed;
|
||||||
|
fetchExtraCompleted(m, completed);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => loadingLibrary = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
// Re-fetch library and reset hero chapters whenever the reader closes,
|
||||||
@@ -59,8 +70,8 @@
|
|||||||
loadLibrary();
|
loadLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[]) {
|
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
|
||||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||||
if (!missingIds.length) return;
|
if (!missingIds.length) return;
|
||||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||||
@@ -162,7 +173,11 @@
|
|||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
openReader(chapter, all);
|
if (all.length) {
|
||||||
|
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
||||||
|
store.activeManga = manga;
|
||||||
|
openReader(chapter, all);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -177,8 +192,10 @@
|
|||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
||||||
if (ch) openReader(ch, chapters);
|
if (ch) {
|
||||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, chapters);
|
||||||
|
}
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
@@ -188,8 +205,10 @@
|
|||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
||||||
if (ch) openReader(ch, chapters);
|
if (ch) {
|
||||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
|
openReader(ch, chapters);
|
||||||
|
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +225,9 @@
|
|||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||||
|
|
||||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
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);
|
||||||
|
|
||||||
@@ -404,7 +423,7 @@
|
|||||||
<div class="bottom-section-hd">
|
<div class="bottom-section-hd">
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
@@ -571,9 +590,10 @@
|
|||||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||||
|
.mini-row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.mini-card:hover { will-change: transform; }
|
.mini-card:hover { will-change: transform; }
|
||||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
||||||
|
|||||||
+766
-102
@@ -1,40 +1,231 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } 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_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
||||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
|
||||||
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
|
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
|
const COMPLETED_NAME = "Completed";
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
// Drag type discriminators (tab reorder only — manga cards no longer use drag).
|
||||||
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
const DT_TAB = "application/x-moku-tab";
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string|null = $state(null);
|
|
||||||
let retryCount: number = $state(0);
|
|
||||||
let search: string = $state("");
|
|
||||||
let renderVisible: number = $state(0);
|
|
||||||
let scrollEl: HTMLDivElement;
|
|
||||||
let containerWidth: number = $state(800);
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let activeDragKind: "tab" | null = $state(null);
|
||||||
|
let dragInsertIdx: number = $state(-1);
|
||||||
|
|
||||||
$effect(() => {
|
// ── Sort / filter panel ───────────────────────────────────────────────────
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
});
|
|
||||||
|
|
||||||
function fetchLibrary() {
|
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 loading: boolean = $state(true);
|
||||||
|
let error: string|null = $state(null);
|
||||||
|
let retryCount: number = $state(0);
|
||||||
|
let search: string = $state("");
|
||||||
|
let renderVisible: number = $state(0);
|
||||||
|
let scrollEl: HTMLDivElement;
|
||||||
|
let containerWidth: number = $state(800);
|
||||||
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let emptyCtx:{ x: number; y: number } | null = $state(null);
|
||||||
|
|
||||||
|
// ── Multi-select ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let selectedIds: Set<number> = $state(new Set());
|
||||||
|
let selectMode: boolean = $state(false);
|
||||||
|
let bulkWorking: boolean = $state(false);
|
||||||
|
// Which folder-move popup is open (shows inline folder list)
|
||||||
|
let bulkMoveOpen: boolean = $state(false);
|
||||||
|
|
||||||
|
function enterSelectMode(id?: number) {
|
||||||
|
selectMode = true;
|
||||||
|
if (id !== undefined) selectedIds = new Set([id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitSelectMode() {
|
||||||
|
selectMode = false;
|
||||||
|
selectedIds = new Set();
|
||||||
|
bulkMoveOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
if (next.size === 0) exitSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedIds = new Set(visibleManga.map(m => m.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-press to enter select mode on touch devices
|
||||||
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
||||||
|
if (e.button !== 0) return; // only primary
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
longPressTimer = null;
|
||||||
|
enterSelectMode(m.id);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
function onCardPointerUp() {
|
||||||
|
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||||
|
}
|
||||||
|
function onCardPointerLeave() {
|
||||||
|
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardClick(e: MouseEvent, m: Manga) {
|
||||||
|
if (selectMode) {
|
||||||
|
toggleSelect(m.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cmd/Ctrl+click or Shift+click enters select mode
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
enterSelectMode(m.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.activeManga = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk mutations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function bulkMoveToCategory(cat: Category) {
|
||||||
|
bulkWorking = true;
|
||||||
|
bulkMoveOpen = false;
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
[...selectedIds].map(id => {
|
||||||
|
const manga = allManga.find(m => m.id === id);
|
||||||
|
if (!manga) return Promise.resolve();
|
||||||
|
return toggleMangaCategory(manga, cat);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
bulkWorking = false;
|
||||||
|
exitSelectMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkRemoveFromLibrary() {
|
||||||
|
bulkWorking = true;
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
[...selectedIds].map(id => {
|
||||||
|
const manga = allManga.find(m => m.id === id);
|
||||||
|
if (!manga) return Promise.resolve();
|
||||||
|
return removeFromLibrary(manga);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
bulkWorking = false;
|
||||||
|
exitSelectMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Completed category auto-create ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function ensureCompletedCategory(cats: Category[]): Promise<Category[]> {
|
||||||
|
if (cats.some(c => c.name === COMPLETED_NAME)) return cats;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: COMPLETED_NAME });
|
||||||
|
return [...cats, res.createCategory.category];
|
||||||
|
} catch { return cats; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function reloadCategories() {
|
||||||
|
try {
|
||||||
|
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||||
|
const cats = await ensureCompletedCategory(d.categories.nodes);
|
||||||
|
setCategories(cats);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLibrary() {
|
||||||
return cache.get(
|
return cache.get(
|
||||||
CACHE_KEYS.LIBRARY,
|
CACHE_KEYS.LIBRARY,
|
||||||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
||||||
@@ -43,11 +234,20 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadData() {
|
async function loadData() {
|
||||||
fetchLibrary()
|
try {
|
||||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
const [nodes] = await Promise.all([loadLibrary(), reloadCategories()]);
|
||||||
.catch(e => error = e.message)
|
const mapped = nodes.map((m: any) => ({
|
||||||
.finally(() => loading = false);
|
...m,
|
||||||
|
chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0,
|
||||||
|
}));
|
||||||
|
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
||||||
|
error = null;
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -57,59 +257,123 @@
|
|||||||
untrack(() => loadData());
|
untrack(() => loadData());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
|
|
||||||
$effect(() => {
|
|
||||||
const allIds = new Set(allManga.map(m => m.id));
|
|
||||||
const missingIds = store.settings.folders
|
|
||||||
.flatMap(f => f.mangaIds)
|
|
||||||
.filter(id => !allIds.has(id));
|
|
||||||
if (!missingIds.length) return;
|
|
||||||
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
|
|
||||||
if (!toFetch.length) return;
|
|
||||||
untrack(() => {
|
|
||||||
Promise.all(
|
|
||||||
toFetch.map(id =>
|
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () =>
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
|
|
||||||
).catch(() => null)
|
|
||||||
)
|
|
||||||
).then(results => {
|
|
||||||
const valid = results.filter(Boolean) as Manga[];
|
|
||||||
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
const f = store.libraryFilter;
|
||||||
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
if (f === "library" || f === "downloaded") return;
|
||||||
|
const id = Number(f);
|
||||||
|
if (!store.categories.some(c => c.id === id)) {
|
||||||
|
untrack(() => { store.libraryFilter = "library"; });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
// Exit select mode when the filter changes
|
||||||
|
$effect(() => { store.libraryFilter; untrack(() => exitSelectMode()); });
|
||||||
|
|
||||||
// All manga available for folder filtering — library + any extras fetched above
|
let prevChapterId: number | null = null;
|
||||||
const folderPool = $derived((() => {
|
$effect(() => {
|
||||||
const seen = new Set(allManga.map(m => m.id));
|
const wasOpen = prevChapterId !== null;
|
||||||
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
prevChapterId = store.activeChapter?.id ?? null;
|
||||||
|
if (wasOpen && !store.activeChapter) {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
untrack(() => loadData());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const visibleCategories = $derived((() => {
|
||||||
|
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||||
|
return store.categories
|
||||||
|
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.id === defaultId) return -1;
|
||||||
|
if (b.id === defaultId) return 1;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
})());
|
||||||
|
|
||||||
|
const categoryMangaMap = $derived((() => {
|
||||||
|
const map = new Map<number, Manga[]>();
|
||||||
|
for (const cat of store.categories) {
|
||||||
|
const nodes = cat.mangas?.nodes ?? [];
|
||||||
|
map.set(cat.id, nodes);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
const filtered = $derived((() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
|
const mode = tabSortMode;
|
||||||
|
const dir = tabSortDir;
|
||||||
|
const status = tabStatus;
|
||||||
|
|
||||||
|
// 1. Pick the right base list for this tab
|
||||||
|
let items: Manga[];
|
||||||
if (store.libraryFilter === "library") {
|
if (store.libraryFilter === "library") {
|
||||||
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
items = allManga;
|
||||||
|
} else if (store.libraryFilter === "downloaded") {
|
||||||
|
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||||
|
} else {
|
||||||
|
items = categoryMangaMap.get(Number(store.libraryFilter)) ?? [];
|
||||||
}
|
}
|
||||||
if (store.libraryFilter === "downloaded") {
|
|
||||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
// 2. NSFW filter — always applied before text search or sort
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
items = items.filter(m => !shouldHideNsfw(m, store.settings));
|
||||||
|
|
||||||
|
// 3. Text search
|
||||||
|
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||||
|
|
||||||
|
// 4. Status filter
|
||||||
|
if (status !== "ALL") {
|
||||||
|
items = items.filter(m => {
|
||||||
|
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
|
||||||
|
return s === status;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
|
||||||
if (folder) {
|
// 5. Sort
|
||||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
const recentlyReadMap = new Map<number, number>();
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
if (mode === "recentlyRead") {
|
||||||
|
for (const h of store.history) {
|
||||||
|
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
|
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))));
|
||||||
@@ -119,18 +383,98 @@
|
|||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
||||||
|
|
||||||
const counts = $derived({
|
const counts = $derived((() => {
|
||||||
library: allManga.length,
|
const m: Record<string, number> = {
|
||||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
library: allManga.length,
|
||||||
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||||
});
|
};
|
||||||
|
for (const cat of visibleCategories) {
|
||||||
|
m[String(cat.id)] = (categoryMangaMap.get(cat.id) ?? []).length;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})());
|
||||||
|
|
||||||
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
||||||
|
|
||||||
|
// ── Drag: tab reorder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dragTabId: number | null = $state(null);
|
||||||
|
let dragOverTabId: number | null = $state(null);
|
||||||
|
let dropTargetTabId: number | null = $state(null);
|
||||||
|
|
||||||
|
function onTabDragStart(e: DragEvent, cat: Category) {
|
||||||
|
activeDragKind = "tab";
|
||||||
|
dragTabId = cat.id;
|
||||||
|
e.dataTransfer!.effectAllowed = "move";
|
||||||
|
e.dataTransfer!.setData(DT_TAB, String(cat.id));
|
||||||
|
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||||||
|
if (activeDragKind !== "tab") return;
|
||||||
|
if (dragTabId === null || dragTabId === cat.id) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = "move";
|
||||||
|
dragOverTabId = cat.id;
|
||||||
|
dragInsertIdx = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabDragLeave() {
|
||||||
|
dragOverTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverTabId = null;
|
||||||
|
dragInsertIdx = -1;
|
||||||
|
|
||||||
|
if (activeDragKind !== "tab") return;
|
||||||
|
if (dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
||||||
|
|
||||||
|
const dragId = dragTabId;
|
||||||
|
dragTabId = null;
|
||||||
|
activeDragKind = null;
|
||||||
|
|
||||||
|
const sorted = [...store.categories]
|
||||||
|
.filter(c => c.id !== 0)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
||||||
|
const toIdx = sorted.findIndex(c => c.id === dropCat.id);
|
||||||
|
if (fromIdx < 0 || toIdx < 0) return;
|
||||||
|
|
||||||
|
const reordered = [...sorted];
|
||||||
|
const [moved] = reordered.splice(fromIdx, 1);
|
||||||
|
reordered.splice(toIdx, 0, moved);
|
||||||
|
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
||||||
|
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
||||||
|
|
||||||
|
const newPos = toIdx + 1;
|
||||||
|
try {
|
||||||
|
await gql<{ updateCategoryOrder: { categories: Category[] } }>(
|
||||||
|
UPDATE_CATEGORY_ORDER,
|
||||||
|
{ id: dragId, position: newPos },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Tab reorder failed:", err);
|
||||||
|
await reloadCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabDragEnd() {
|
||||||
|
activeDragKind = null;
|
||||||
|
dragTabId = null;
|
||||||
|
dragOverTabId = null;
|
||||||
|
dragInsertIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
async function removeFromLibrary(manga: Manga) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
allManga = allManga.filter(m => m.id !== manga.id);
|
allManga = allManga.filter(m => m.id !== manga.id);
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
|
await reloadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
@@ -144,32 +488,126 @@
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }
|
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||||
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
||||||
|
setCategories(store.categories.map(c => {
|
||||||
|
if (c.id !== cat.id || !c.mangas) return c;
|
||||||
|
const nodes = inCat
|
||||||
|
? c.mangas.nodes.filter(m => m.id !== manga.id)
|
||||||
|
: [...c.mangas.nodes, manga];
|
||||||
|
return { ...c, mangas: { nodes } };
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||||
|
mangaId: manga.id,
|
||||||
|
addTo: inCat ? [] : [cat.id],
|
||||||
|
removeFrom: inCat ? [cat.id] : [],
|
||||||
|
});
|
||||||
|
await reloadCategories();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
await reloadCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAssign(manga: Manga) {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
await reloadCategories();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Context menu ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
if (selectMode) { toggleSelect(m.id); return; }
|
||||||
|
e.preventDefault();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
const mangaFolders = getMangaFolders(m.id);
|
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
|
||||||
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
return {
|
||||||
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
|
label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`,
|
||||||
|
icon: Folder,
|
||||||
|
onClick: () => toggleMangaCategory(m, cat),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||||
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||||||
|
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmptyCtx(): MenuEntry[] {
|
function buildEmptyCtx(): MenuEntry[] {
|
||||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); } }];
|
return [{
|
||||||
|
label: "New folder",
|
||||||
|
icon: FolderSimplePlus,
|
||||||
|
onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
await gql(CREATE_CATEGORY, { name: name.trim() });
|
||||||
|
await reloadCategories();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Completed auto-assign ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||||
|
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
await reloadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||||
ro.observe(scrollEl);
|
ro.observe(scrollEl);
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
|
||||||
return () => { ro.disconnect(); unsub(); };
|
|
||||||
|
const defaultId = store.settings.defaultLibraryCategoryId;
|
||||||
|
if (defaultId && store.libraryFilter === "library") {
|
||||||
|
store.libraryFilter = String(defaultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key exits select mode
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) {
|
||||||
|
sortPanelOpen = false; filterPanelOpen = false; return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && selectMode) exitSelectMode();
|
||||||
|
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -223,21 +661,167 @@
|
|||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
{#each visibleCategories as cat, idx}
|
||||||
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
{@const isDefault = (store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||||
<Folder size={11} weight="bold" />
|
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
||||||
{folder.name}
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
{/if}
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={store.libraryFilter === String(cat.id)}
|
||||||
|
class:tab-dragging={dragTabId === cat.id}
|
||||||
|
class:tab-drop-target={dropTargetTabId === cat.id}
|
||||||
|
class:tab-default={isDefault}
|
||||||
|
draggable="true"
|
||||||
|
onclick={() => store.libraryFilter = String(cat.id)}
|
||||||
|
ondragstart={(e) => onTabDragStart(e, cat)}
|
||||||
|
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
||||||
|
ondragleave={onTabDragLeave}
|
||||||
|
ondrop={(e) => onTabDrop(e, cat)}
|
||||||
|
ondragend={onTabDragEnd}
|
||||||
|
>
|
||||||
|
{#if isDefault}
|
||||||
|
<Star size={11} weight="fill" style="color:var(--accent-fg)" />
|
||||||
|
{:else}
|
||||||
|
<Folder size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
{cat.name}
|
||||||
|
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
||||||
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-wrap">
|
<div class="header-right">
|
||||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
<div class="search-wrap">
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
||||||
|
<input class="search" placeholder="Search" bind:value={search} />
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Selection toolbar ───────────────────────────────────────────────── -->
|
||||||
|
{#if selectMode}
|
||||||
|
<div class="select-bar">
|
||||||
|
<div class="select-bar-left">
|
||||||
|
<button class="sel-btn sel-cancel" onclick={exitSelectMode} title="Cancel (Esc)">
|
||||||
|
<X size={13} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<span class="sel-count">{selectedIds.size} selected</span>
|
||||||
|
<button class="sel-btn sel-all" onclick={selectAll} title="Select all (⌘A)">
|
||||||
|
Select all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="select-bar-right">
|
||||||
|
{#if visibleCategories.length}
|
||||||
|
<div class="bulk-move-wrap">
|
||||||
|
<button
|
||||||
|
class="sel-btn sel-move"
|
||||||
|
disabled={selectedIds.size === 0 || bulkWorking}
|
||||||
|
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
||||||
|
>
|
||||||
|
<Folder size={13} weight="bold" />
|
||||||
|
Move to folder
|
||||||
|
</button>
|
||||||
|
{#if bulkMoveOpen}
|
||||||
|
<div class="bulk-folder-list">
|
||||||
|
{#each visibleCategories as cat}
|
||||||
|
<button class="bulk-folder-item" onclick={() => bulkMoveToCategory(cat)}>
|
||||||
|
<Folder size={11} weight="bold" />
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="sel-btn sel-remove"
|
||||||
|
disabled={selectedIds.size === 0 || bulkWorking}
|
||||||
|
onclick={bulkRemoveFromLibrary}
|
||||||
|
>
|
||||||
|
<Trash size={13} weight="bold" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -257,11 +841,32 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid" style="--cols:{cols}">
|
<div class="grid" style="--cols:{cols}">
|
||||||
{#each visibleManga as m (m.id)}
|
{#each visibleManga as m (m.id)}
|
||||||
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
{@const isSelected = selectedIds.has(m.id)}
|
||||||
|
<button
|
||||||
|
class="card"
|
||||||
|
class:card-selected={isSelected}
|
||||||
|
class:select-mode={selectMode}
|
||||||
|
onclick={(e) => onCardClick(e, m)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, m)}
|
||||||
|
onpointerdown={(e) => onCardPointerDown(e, m)}
|
||||||
|
onpointerup={onCardPointerUp}
|
||||||
|
onpointerleave={onCardPointerLeave}
|
||||||
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" draggable="false" />
|
||||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||||||
|
{#if selectMode}
|
||||||
|
<div class="select-overlay" aria-hidden="true">
|
||||||
|
<div class="select-check" class:checked={isSelected}>
|
||||||
|
{#if isSelected}
|
||||||
|
<CheckSquare size={20} weight="fill" />
|
||||||
|
{:else}
|
||||||
|
<div class="select-check-empty"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="title">{m.title}</p>
|
<p class="title">{m.title}</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -288,32 +893,91 @@
|
|||||||
{/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; }
|
||||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; }
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||||
|
.tab-default { color: var(--text-muted); }
|
||||||
|
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||||
|
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
|
||||||
|
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||||
.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); }
|
||||||
.grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
|
||||||
|
/* ── 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 ──────────────────────────────────────────────────── */
|
||||||
|
.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-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; }
|
||||||
|
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||||
|
.sel-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||||
|
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.sel-cancel { border-color: transparent; background: transparent; }
|
||||||
|
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
|
||||||
|
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
|
||||||
|
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
|
||||||
|
.sel-all { border-color: transparent; background: transparent; }
|
||||||
|
|
||||||
|
/* Bulk folder dropdown */
|
||||||
|
.bulk-move-wrap { position: relative; }
|
||||||
|
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
||||||
|
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 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); }
|
||||||
|
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Grid & cards ───────────────────────────────────────────────────────── */
|
||||||
|
.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); }
|
||||||
.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); }
|
.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 .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); }
|
||||||
.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); }
|
||||||
|
|
||||||
|
/* Select overlay (checkbox) */
|
||||||
|
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
||||||
|
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
||||||
|
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
||||||
|
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
|
||||||
|
|
||||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
|
|||||||
+111
-303
@@ -3,7 +3,7 @@
|
|||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||||
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
|
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
|
||||||
@@ -91,14 +91,11 @@
|
|||||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
// ── Keyword search ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let kw_query = $state("");
|
let kw_query = $state("");
|
||||||
let kw_submitted = $state("");
|
let kw_submitted = $state("");
|
||||||
let kw_results: SourceResult[] = $state([]);
|
let kw_results: SourceResult[] = $state([]);
|
||||||
let kw_showAdvanced = $state(false);
|
let kw_showAdvanced = $state(false);
|
||||||
let kw_selectedLangs: Set<string> = $state(new Set());
|
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||||
let kw_includeNsfw = $state(false);
|
|
||||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
let kw_abortCtrl: AbortController | null = null;
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
@@ -124,7 +121,7 @@
|
|||||||
let filtered = allSources;
|
let filtered = allSources;
|
||||||
if (kw_selectedLangs.size > 0)
|
if (kw_selectedLangs.size > 0)
|
||||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
if (!kw_includeNsfw)
|
if (!store.settings.showNsfw)
|
||||||
filtered = filtered.filter((s) => !s.isNsfw);
|
filtered = filtered.filter((s) => !s.isNsfw);
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@@ -146,8 +143,9 @@
|
|||||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
kw_results = kw_results.map((r) =>
|
kw_results = kw_results.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
|
r.source.id === src.id ? { ...r, mangas, loading: false } : r,
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
@@ -169,8 +167,6 @@
|
|||||||
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||||
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||||
|
|
||||||
// ── Tag search ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let tag_activeTags: string[] = $state([]);
|
let tag_activeTags: string[] = $state([]);
|
||||||
let tag_tagMode: TagMode = $state("AND");
|
let tag_tagMode: TagMode = $state("AND");
|
||||||
let tag_tagFilter = $state("");
|
let tag_tagFilter = $state("");
|
||||||
@@ -243,7 +239,8 @@
|
|||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
).then((d) => {
|
).then((d) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
tag_localResults = d.mangas.nodes;
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
|
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
|
||||||
tag_totalCount = d.mangas.totalCount;
|
tag_totalCount = d.mangas.totalCount;
|
||||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset = (store.settings.renderLimit ?? 48);
|
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||||
@@ -279,9 +276,10 @@
|
|||||||
ps.add(1);
|
ps.add(1);
|
||||||
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
|
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
|
||||||
tag_srcNextPage = new Map(tag_srcNextPage);
|
tag_srcNextPage = new Map(tag_srcNextPage);
|
||||||
const matching = activeTags.length > 1
|
const matching = (activeTags.length > 1
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
||||||
: result.mangas;
|
: result.mangas
|
||||||
|
).filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
||||||
tag_loadingSourceSearch = false;
|
tag_loadingSourceSearch = false;
|
||||||
@@ -304,7 +302,7 @@
|
|||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
tag_localResults = [...tag_localResults, ...d.mangas.nodes];
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset += (store.settings.renderLimit ?? 48);
|
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -340,9 +338,10 @@
|
|||||||
ps.add(page);
|
ps.add(page);
|
||||||
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
|
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
tag_srcNextPage = new Map(tag_srcNextPage);
|
tag_srcNextPage = new Map(tag_srcNextPage);
|
||||||
const matching = tag_activeTags.length > 1
|
const matching = (tag_activeTags.length > 1
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
||||||
: result.mangas;
|
: result.mangas
|
||||||
|
).filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
@@ -367,9 +366,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source browse ─────────────────────────────────────────────────────────
|
let src_selectedLang = $state(preferredLang || "all");
|
||||||
|
|
||||||
let src_selectedLang = $state("all");
|
|
||||||
let src_activeSource: Source | null = $state(null);
|
let src_activeSource: Source | null = $state(null);
|
||||||
let src_browseResults: Manga[] = $state([]);
|
let src_browseResults: Manga[] = $state([]);
|
||||||
let src_loadingBrowse = $state(false);
|
let src_loadingBrowse = $state(false);
|
||||||
@@ -378,40 +375,33 @@
|
|||||||
let src_hasNextPage = $state(false);
|
let src_hasNextPage = $state(false);
|
||||||
let src_currentPage = $state(1);
|
let src_currentPage = $state(1);
|
||||||
let src_abortCtrl: AbortController | null = null;
|
let src_abortCtrl: AbortController | null = null;
|
||||||
let src_langPocketOpen = $state(true);
|
|
||||||
let src_expandedGroups: Set<string> = $state(new Set());
|
|
||||||
|
|
||||||
// Group sources by displayName — sources with same name but different langs get grouped
|
$effect(() => {
|
||||||
interface SourceGroup {
|
if (!allSources.length) return;
|
||||||
name: string;
|
const langs = new Set(allSources.map((s) => s.lang));
|
||||||
iconUrl: string;
|
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
|
||||||
sources: Source[];
|
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
|
||||||
isNsfw: boolean;
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const src_visibleSources = $derived(src_selectedLang === "all"
|
const src_visibleSources = $derived.by(() => {
|
||||||
? allSources
|
const nsfw = (s: Source) => !store.settings.showNsfw && s.isNsfw;
|
||||||
: allSources.filter((s) => s.lang === src_selectedLang));
|
if (src_selectedLang !== "all") {
|
||||||
|
return allSources.filter((s) => s.lang === src_selectedLang && !nsfw(s));
|
||||||
const src_groupedSources = $derived.by(() => {
|
}
|
||||||
const filtered = src_visibleSources;
|
const map = new Map<string, Source>();
|
||||||
const map = new Map<string, SourceGroup>();
|
for (const s of allSources) {
|
||||||
for (const src of filtered) {
|
if (nsfw(s)) continue;
|
||||||
const key = src.displayName;
|
const key = s.name;
|
||||||
if (!map.has(key)) {
|
const existing = map.get(key);
|
||||||
map.set(key, { name: src.displayName, iconUrl: src.iconUrl, sources: [], isNsfw: src.isNsfw });
|
if (!existing) { map.set(key, s); continue; }
|
||||||
|
if (s.lang === preferredLang || (!existing || (existing.lang !== preferredLang && s.lang < existing.lang))) {
|
||||||
|
map.set(key, s);
|
||||||
}
|
}
|
||||||
map.get(key)!.sources.push(src);
|
|
||||||
}
|
}
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
|
|
||||||
function srcToggleGroup(name: string) {
|
|
||||||
const next = new Set(src_expandedGroups);
|
|
||||||
if (next.has(name)) next.delete(name); else next.add(name);
|
|
||||||
src_expandedGroups = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
src_abortCtrl?.abort();
|
src_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -422,7 +412,8 @@
|
|||||||
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
|
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas];
|
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
|
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||||
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||||
src_currentPage = page;
|
src_currentPage = page;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -570,10 +561,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="advancedDivider"></div>
|
<div class="advancedDivider"></div>
|
||||||
<label class="advancedCheck">
|
|
||||||
<input type="checkbox" bind:checked={kw_includeNsfw} class="checkbox" />
|
|
||||||
Include NSFW sources
|
|
||||||
</label>
|
|
||||||
<div class="advancedFooter">
|
<div class="advancedFooter">
|
||||||
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -867,27 +854,18 @@
|
|||||||
<div class="splitRoot">
|
<div class="splitRoot">
|
||||||
|
|
||||||
<div class="splitSidebar">
|
<div class="splitSidebar">
|
||||||
<button class="langPocketToggle" onclick={() => (src_langPocketOpen = !src_langPocketOpen)}>
|
<div class="srcLangRow">
|
||||||
<span class="langPocketLabel">Languages</span>
|
<span class="langPocketLabel">Language</span>
|
||||||
<svg width="9" height="9" viewBox="0 0 256 256" fill="currentColor"
|
<select
|
||||||
style="transition: transform 0.2s ease; transform: rotate({src_langPocketOpen ? 180 : 0}deg)"
|
class="langSelect"
|
||||||
aria-hidden="true">
|
bind:value={src_selectedLang}
|
||||||
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/>
|
>
|
||||||
</svg>
|
<option value="all">All</option>
|
||||||
</button>
|
{#each availableLangs as lang (lang)}
|
||||||
{#if src_langPocketOpen}
|
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
|
||||||
<div class="langPocket">
|
{/each}
|
||||||
{#each ["all", ...availableLangs] as lang (lang)}
|
</select>
|
||||||
<button
|
</div>
|
||||||
class="langChip"
|
|
||||||
class:langChipActive={src_selectedLang === lang}
|
|
||||||
onclick={() => (src_selectedLang = lang)}
|
|
||||||
>
|
|
||||||
{lang === "all" ? "All" : lang.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loadingSources}
|
{#if loadingSources}
|
||||||
<div class="splitLoading">
|
<div class="splitLoading">
|
||||||
@@ -897,52 +875,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="splitList">
|
<div class="splitList">
|
||||||
{#each src_groupedSources as group (group.name)}
|
{#each src_visibleSources as src (src.id)}
|
||||||
{#if group.sources.length === 1}
|
<button
|
||||||
<button
|
class="splitItem splitItemSource"
|
||||||
class="splitItem splitItemSource"
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
class:splitItemActive={src_activeSource?.id === group.sources[0].id}
|
onclick={() => srcSelectSource(src)}
|
||||||
onclick={() => srcSelectSource(group.sources[0])}
|
>
|
||||||
>
|
<img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon"
|
||||||
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
<span class="splitItemLabel">{group.name}</span>
|
{#if src_selectedLang === "all"}
|
||||||
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{group.sources[0].lang.toUpperCase()}</span>
|
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
|
||||||
{#if group.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="splitItem splitItemSource splitItemGroup"
|
|
||||||
class:splitItemGroupOpen={src_expandedGroups.has(group.name)}
|
|
||||||
onclick={() => srcToggleGroup(group.name)}
|
|
||||||
>
|
|
||||||
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
|
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<span class="splitItemLabel">{group.name}</span>
|
|
||||||
<span class="groupLangCount">{group.sources.length}</span>
|
|
||||||
<svg width="8" height="8" viewBox="0 0 256 256" fill="currentColor"
|
|
||||||
class="groupChevron"
|
|
||||||
style="transform: rotate({src_expandedGroups.has(group.name) ? 180 : 0}deg)"
|
|
||||||
aria-hidden="true">
|
|
||||||
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{#if src_expandedGroups.has(group.name)}
|
|
||||||
{#each group.sources as src (src.id)}
|
|
||||||
<button
|
|
||||||
class="splitItem splitItemSource splitItemLangOption"
|
|
||||||
class:splitItemActive={src_activeSource?.id === src.id}
|
|
||||||
onclick={() => srcSelectSource(src)}
|
|
||||||
>
|
|
||||||
<span class="langOptionDot"></span>
|
|
||||||
<span class="splitItemLabel">{src.lang.toUpperCase()}</span>
|
|
||||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if src_groupedSources.length === 0}
|
{#if src_visibleSources.length === 0}
|
||||||
<p class="splitEmpty">No sources for this language</p>
|
<p class="splitEmpty">No sources for this language</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1061,8 +1009,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Root ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1070,9 +1016,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: fadeIn 0.14s ease both;
|
animation: fadeIn 0.14s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1081,7 +1024,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -1090,9 +1032,6 @@
|
|||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tabs ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1101,7 +1040,6 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1113,23 +1051,19 @@
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-base), color var(--t-base);
|
transition: background var(--t-base), color var(--t-base);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
border: 1px solid var(--accent-dim);
|
border: 1px solid var(--accent-dim);
|
||||||
}
|
}
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
/* ── Keyword bar ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.keywordBar {
|
.keywordBar {
|
||||||
padding: var(--sp-3) var(--sp-4);
|
padding: var(--sp-3) var(--sp-4);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1137,7 +1071,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchBar {
|
.searchBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1149,9 +1082,7 @@
|
|||||||
transition: border-color var(--t-base);
|
transition: border-color var(--t-base);
|
||||||
}
|
}
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1162,7 +1093,6 @@
|
|||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
}
|
}
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
.clearBtn {
|
.clearBtn {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1174,7 +1104,6 @@
|
|||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
.clearBtn:hover { color: var(--text-muted); }
|
.clearBtn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
.advancedBtn {
|
.advancedBtn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1192,7 +1121,6 @@
|
|||||||
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.searchBtn {
|
.searchBtn {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -1211,9 +1139,6 @@
|
|||||||
}
|
}
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
/* ── Advanced filter panel ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.advancedPanel {
|
.advancedPanel {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
@@ -1224,13 +1149,11 @@
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
animation: fadeIn 0.1s ease both;
|
animation: fadeIn 0.1s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedHeader {
|
.advancedHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedTitle {
|
.advancedTitle {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1238,9 +1161,7 @@
|
|||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedActions { display: flex; gap: var(--sp-1); }
|
.advancedActions { display: flex; gap: var(--sp-1); }
|
||||||
|
|
||||||
.advancedLink {
|
.advancedLink {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1254,13 +1175,11 @@
|
|||||||
transition: opacity var(--t-base);
|
transition: opacity var(--t-base);
|
||||||
}
|
}
|
||||||
.advancedLink:hover { opacity: 1; }
|
.advancedLink:hover { opacity: 1; }
|
||||||
|
|
||||||
.langGrid {
|
.langGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.langChip {
|
.langChip {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1274,20 +1193,17 @@
|
|||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
.langChipActive {
|
.langChipActive {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
|
||||||
.advancedDivider {
|
.advancedDivider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-dim);
|
background: var(--border-dim);
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedCheck {
|
.advancedCheck {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1297,16 +1213,13 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox { accent-color: var(--accent-fg); cursor: pointer; }
|
.checkbox { accent-color: var(--accent-fg); cursor: pointer; }
|
||||||
|
|
||||||
.advancedFooter {
|
.advancedFooter {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedLinkStandalone {
|
.advancedLinkStandalone {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1323,9 +1236,6 @@
|
|||||||
transition: opacity var(--t-base);
|
transition: opacity var(--t-base);
|
||||||
}
|
}
|
||||||
.advancedLinkStandalone:hover { opacity: 1; }
|
.advancedLinkStandalone:hover { opacity: 1; }
|
||||||
|
|
||||||
/* ── Empty states ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1334,33 +1244,26 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
.emptyIcon { color: var(--text-faint); }
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||||
|
|
||||||
/* ── Keyword results ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceSection {
|
.sourceSection {
|
||||||
padding: var(--sp-1) var(--sp-4) var(--sp-3);
|
padding: var(--sp-1) var(--sp-4) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
.sourceSection:last-child { border-bottom: none; }
|
.sourceSection:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.sourceHeader {
|
.sourceHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceIcon {
|
.sourceIcon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -1369,13 +1272,11 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceName {
|
.sourceName {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceLang {
|
.sourceLang {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1386,7 +1287,6 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resultCount {
|
.resultCount {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
@@ -1394,15 +1294,12 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceError {
|
.sourceError {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
padding: var(--sp-1) 0;
|
padding: var(--sp-1) 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal scroll row */
|
|
||||||
.sourceRow {
|
.sourceRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
@@ -1411,9 +1308,6 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.sourceRow::-webkit-scrollbar { display: none; }
|
.sourceRow::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
/* ── Manga card ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1428,7 +1322,6 @@
|
|||||||
}
|
}
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
.card:hover .cardTitle { color: var(--text-primary); }
|
.card:hover .cardTitle { color: var(--text-primary); }
|
||||||
|
|
||||||
.coverWrap {
|
.coverWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1439,14 +1332,12 @@
|
|||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transition: filter var(--t-base);
|
transition: filter var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inLibBadge {
|
.inLibBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: var(--sp-1);
|
bottom: var(--sp-1);
|
||||||
@@ -1461,7 +1352,6 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--accent-muted);
|
border: 1px solid var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1472,9 +1362,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton ──────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.skCard {
|
.skCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1482,30 +1369,20 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagGrid .card { width: 100%; }
|
.tagGrid .card { width: 100%; }
|
||||||
.tagGrid .skCard { width: 100%; }
|
.tagGrid .skCard { width: 100%; }
|
||||||
|
|
||||||
.skeleton { border-radius: var(--radius-sm); }
|
.skeleton { border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
.skCover {
|
.skCover {
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skTitle { height: 10px; width: 80%; }
|
.skTitle { height: 10px; width: 80%; }
|
||||||
|
|
||||||
/* ── Split root (Tag + Source tabs) ────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitRoot {
|
.splitRoot {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Split sidebar ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitSidebar {
|
.splitSidebar {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1514,7 +1391,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitSearchWrap {
|
.splitSearchWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1523,9 +1399,7 @@
|
|||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
.splitSearchInput {
|
.splitSearchInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1537,7 +1411,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
.splitSearchClear {
|
.splitSearchClear {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1549,7 +1422,6 @@
|
|||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
.splitSearchClear:hover { color: var(--text-muted); }
|
.splitSearchClear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
.splitList {
|
.splitList {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1557,7 +1429,6 @@
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-dim) transparent;
|
scrollbar-color: var(--border-dim) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitItem {
|
.splitItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1572,13 +1443,11 @@
|
|||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
}
|
}
|
||||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
|
||||||
.splitItemActive {
|
.splitItemActive {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
}
|
}
|
||||||
.splitItemActive:hover { background: var(--accent-muted); }
|
.splitItemActive:hover { background: var(--accent-muted); }
|
||||||
|
|
||||||
.splitItemLabel {
|
.splitItemLabel {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1588,9 +1457,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
|
|
||||||
.splitItemSource { gap: var(--sp-2); }
|
.splitItemSource { gap: var(--sp-2); }
|
||||||
|
|
||||||
.splitEmpty {
|
.splitEmpty {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -1598,7 +1465,6 @@
|
|||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitLoading {
|
.splitLoading {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1606,16 +1472,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--sp-6);
|
padding: var(--sp-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Split content ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitContent {
|
.splitContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitContentHeader {
|
.splitContentHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1625,7 +1487,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitSourceTitle {
|
.splitSourceTitle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1633,7 +1494,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitContentTitle {
|
.splitContentTitle {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
@@ -1643,7 +1503,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitResultCount {
|
.splitResultCount {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1651,7 +1510,6 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitSourceIcon {
|
.splitSourceIcon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -1660,9 +1518,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tag active bar ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tagActiveBar {
|
.tagActiveBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1672,7 +1527,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagPillRow {
|
.tagPillRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1680,7 +1534,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagPill {
|
.tagPill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1694,7 +1547,6 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagPillRemove {
|
.tagPillRemove {
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -1707,21 +1559,18 @@
|
|||||||
transition: opacity var(--t-base);
|
transition: opacity var(--t-base);
|
||||||
}
|
}
|
||||||
.tagPillRemove:hover { opacity: 1; }
|
.tagPillRemove:hover { opacity: 1; }
|
||||||
|
|
||||||
.tagBarRight {
|
.tagBarRight {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagModeToggle {
|
.tagModeToggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagModeBtn {
|
.tagModeBtn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1741,7 +1590,6 @@
|
|||||||
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.tagClearAll {
|
.tagClearAll {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1761,15 +1609,11 @@
|
|||||||
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagCheckMark {
|
.tagCheckMark {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Grid results ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tagGrid {
|
.tagGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
@@ -1779,9 +1623,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Show more / load more ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.showMoreCell {
|
.showMoreCell {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1789,7 +1630,6 @@
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.showMoreBtn {
|
.showMoreBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1811,7 +1651,6 @@
|
|||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.loadMoreRow {
|
.loadMoreRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1819,9 +1658,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Source tab: lang filter + browse bar ──────────────────────────────── */
|
|
||||||
|
|
||||||
.sourceBrowseBar {
|
.sourceBrowseBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1830,9 +1666,54 @@
|
|||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.srcLangRow {
|
||||||
/* ── NSFW badge ────────────────────────────────────────────────────────── */
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.langPocketLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.langSelect {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 4px 24px 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 110px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 7px center;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.langSelect:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background-color: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.langSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.langSelect option {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
.nsfwBadge {
|
.nsfwBadge {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
@@ -1845,81 +1726,8 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Language pocket ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.langPocketToggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
border-top: none;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.langPocketToggle:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.langPocketLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.langPocket {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Source group (multi-lang) ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitItemGroup { }
|
.splitItemGroup { }
|
||||||
.splitItemGroupOpen { background: var(--bg-raised); }
|
.splitItemGroupOpen { background: var(--bg-raised); }
|
||||||
|
|
||||||
.groupLangCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0px 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupChevron {
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitItemLangOption {
|
|
||||||
padding-left: var(--sp-5);
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
}
|
|
||||||
.splitItemLangOption:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.langOptionDot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--border-strong);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.splitItemActive .langOptionDot { background: var(--accent-fg); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script module>
|
<script module>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA } from "../../lib/queries";
|
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte";
|
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||||
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
import MigrateModal from "./MigrateModal.svelte";
|
||||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||||
@@ -36,6 +37,9 @@
|
|||||||
let folderPickerOpen: boolean = $state(false);
|
let folderPickerOpen: boolean = $state(false);
|
||||||
let folderCreating: boolean = $state(false);
|
let folderCreating: boolean = $state(false);
|
||||||
let folderNewName: string = $state("");
|
let folderNewName: string = $state("");
|
||||||
|
let mangaCategories: Category[] = $state([]);
|
||||||
|
let allCategories: Category[] = $state([]);
|
||||||
|
let catsLoading: boolean = $state(false);
|
||||||
let rangeFrom: string = $state("");
|
let rangeFrom: string = $state("");
|
||||||
let rangeTo: string = $state("");
|
let rangeTo: string = $state("");
|
||||||
let showRange: boolean = $state(false);
|
let showRange: boolean = $state(false);
|
||||||
@@ -83,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);
|
||||||
@@ -102,9 +115,34 @@
|
|||||||
})());
|
})());
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
const hasFolders = $derived(assignedFolders.length > 0);
|
||||||
|
|
||||||
|
function loadCategories(mangaId: number) {
|
||||||
|
catsLoading = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => {
|
||||||
|
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||||
|
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { catsLoading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||||
|
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
// Sync local mangaCategories state after the mutation
|
||||||
|
if (chaps.length) {
|
||||||
|
const allRead = chaps.every(c => c.isRead);
|
||||||
|
const completed = allCategories.find(c => c.name === "Completed");
|
||||||
|
if (completed) {
|
||||||
|
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||||
|
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||||
|
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadManga(id: number) {
|
function loadManga(id: number) {
|
||||||
mangaAbort?.abort();
|
mangaAbort?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -164,7 +202,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const m = store.activeManga;
|
const m = store.activeManga;
|
||||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
||||||
});
|
});
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
@@ -300,14 +338,34 @@
|
|||||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFolder() {
|
async function createCategory() {
|
||||||
const name = folderNewName.trim();
|
const name = folderNewName.trim();
|
||||||
if (!name || !store.activeManga) return;
|
if (!name || !store.activeManga) return;
|
||||||
const id = addFolder(name);
|
try {
|
||||||
assignMangaToFolder(id, store.activeManga.id);
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
allCategories = [...allCategories, cat];
|
||||||
|
mangaCategories = [...mangaCategories, cat];
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
folderNewName = ""; folderCreating = false;
|
folderNewName = ""; folderCreating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleCategory(cat: Category) {
|
||||||
|
if (!store.activeManga) return;
|
||||||
|
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||||
|
try {
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||||
|
mangaId: store.activeManga.id,
|
||||||
|
addTo: inCat ? [] : [cat.id],
|
||||||
|
removeFrom: inCat ? [cat.id] : [],
|
||||||
|
});
|
||||||
|
mangaCategories = inCat
|
||||||
|
? mangaCategories.filter(c => c.id !== cat.id)
|
||||||
|
: [...mangaCategories, cat];
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
|
|
||||||
// ── Series link ────────────────────────────────────────────────────────────
|
// ── Series link ────────────────────────────────────────────────────────────
|
||||||
@@ -395,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}` : ""}`
|
||||||
@@ -505,29 +563,30 @@
|
|||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Folder picker -->
|
<!-- Category picker -->
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||||
</button>
|
</button>
|
||||||
{#if folderPickerOpen}
|
{#if folderPickerOpen}
|
||||||
<div class="fp-menu">
|
<div class="fp-menu">
|
||||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
{#if catsLoading}
|
||||||
|
<p class="fp-empty">Loading…</p>
|
||||||
|
{:else if allCategories.length === 0 && !folderCreating}
|
||||||
<p class="fp-empty">No folders yet</p>
|
<p class="fp-empty">No folders yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each store.settings.folders as folder}
|
{#each allCategories as cat}
|
||||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||||
<button class="fp-item" class:fp-item-active={isIn}
|
<button class="fp-item" class:fp-item-active={isIn} onclick={() => toggleCategory(cat)}>
|
||||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
<span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
|
||||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="fp-div"></div>
|
<div class="fp-div"></div>
|
||||||
{#if folderCreating}
|
{#if folderCreating}
|
||||||
<div class="fp-create">
|
<div class="fp-create">
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||||
<X size={12} weight="light" />
|
<X size={12} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
@@ -615,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>
|
||||||
@@ -627,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, tick } from "svelte";
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, BookmarkSimple } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, resetChapterProgress } from "../../store/state.svelte";
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||||
|
import { setReading } from "../../lib/discord";
|
||||||
import type { FitMode } from "../../store/state.svelte";
|
import type { FitMode } from "../../store/state.svelte";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
@@ -12,6 +13,10 @@
|
|||||||
const AVG_MIN_PER_PAGE = 0.33;
|
const AVG_MIN_PER_PAGE = 0.33;
|
||||||
const MAX_CACHED = 10;
|
const MAX_CACHED = 10;
|
||||||
const READ_LINE_PCT = 0.20;
|
const READ_LINE_PCT = 0.20;
|
||||||
|
// Zoom step per Ctrl+Wheel tick or keyboard shortcut (5% of viewer width)
|
||||||
|
const ZOOM_STEP = 0.05;
|
||||||
|
const ZOOM_MIN = 0.1;
|
||||||
|
const ZOOM_MAX = 4.0;
|
||||||
|
|
||||||
// ─── Page cache ───────────────────────────────────────────────────────────────
|
// ─── Page cache ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -93,6 +98,47 @@
|
|||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
// ─── Container width (for resolution-based zoom) ──────────────────────────────
|
||||||
|
// Tracked via ResizeObserver so 100% zoom always means "fills the viewer",
|
||||||
|
// regardless of screen resolution or window size.
|
||||||
|
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
// ─── Zoom anchor (longstrip) ──────────────────────────────────────────────────
|
||||||
|
// Before zoom changes the layout we snapshot which image is at the top of the
|
||||||
|
// viewport and how far it is from the top edge. After the DOM re-renders at
|
||||||
|
// the new zoom we scroll back so that same image is at the same visual offset,
|
||||||
|
// preventing the "random page teleport" that occurs when scrollHeight changes.
|
||||||
|
|
||||||
|
let zoomAnchorEl: HTMLElement | null = null;
|
||||||
|
let zoomAnchorOffset: number = 0;
|
||||||
|
|
||||||
|
function captureZoomAnchor() {
|
||||||
|
if (!containerEl || style !== "longstrip") return;
|
||||||
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
|
for (const img of imgs) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
if (rect.bottom > containerTop) {
|
||||||
|
zoomAnchorEl = img;
|
||||||
|
zoomAnchorOffset = rect.top - containerTop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreZoomAnchor() {
|
||||||
|
if (!zoomAnchorEl || !containerEl) return;
|
||||||
|
const el = zoomAnchorEl;
|
||||||
|
zoomAnchorEl = null;
|
||||||
|
// Use rAF to wait for the DOM to finish re-laying out after the zoom change.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
|
const newRect = el.getBoundingClientRect();
|
||||||
|
containerEl.scrollTop += (newRect.top - containerTop) - zoomAnchorOffset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── UI state ─────────────────────────────────────────────────────────────────
|
// ─── UI state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -121,21 +167,71 @@
|
|||||||
|
|
||||||
// ─── Derived ──────────────────────────────────────────────────────────────────
|
// ─── Derived ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||||
const style = $derived(store.settings.pageStyle ?? "single");
|
const style = $derived(store.settings.pageStyle ?? "single");
|
||||||
const maxW = $derived(store.settings.maxPageWidth ?? 900);
|
const zoom = $derived(store.settings.readerZoom ?? 1.0);
|
||||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
const lastPage = $derived(store.pageUrls.length);
|
||||||
|
|
||||||
|
// effectiveWidth: how wide the image should be, in pixels.
|
||||||
|
// = container width × zoom multiplier. Applied as max-width on the viewer
|
||||||
|
// so fit modes (height, screen) can still further constrain the image.
|
||||||
|
const effectiveWidth = $derived(
|
||||||
|
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomPct = $derived(Math.round(zoom * 100));
|
||||||
|
|
||||||
|
// ─── Resume / bookmark ────────────────────────────────────────────────────────
|
||||||
|
// resumePage: fixed at component init from history. Never changes after mount.
|
||||||
|
// We read from history directly (not store.pageNumber) because loadChapter
|
||||||
|
// temporarily resets store.pageNumber to 1 during the fetch.
|
||||||
|
const _resumeHistoryPage = store.activeChapter
|
||||||
|
? (store.history.find(h => h.chapterId === store.activeChapter!.id)?.pageNumber ?? 1)
|
||||||
|
: 1;
|
||||||
|
let resumePage = $state(_resumeHistoryPage > 1 ? _resumeHistoryPage : 0);
|
||||||
|
let resumeDismissed = $state(false);
|
||||||
|
// stripResumeReady: flipped to true once the longstrip scroll-to-resume fires.
|
||||||
|
// In single/double mode store.pageNumber drives the banner; in longstrip we
|
||||||
|
// use this flag because store.pageNumber is scroll-observer-driven and may
|
||||||
|
// never exactly equal resumePage after layout shifts from image loading.
|
||||||
|
let stripResumeReady = $state(false);
|
||||||
|
const showResumeBanner = $derived(
|
||||||
|
resumePage > 1 && !resumeDismissed &&
|
||||||
|
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBookmark = $derived(
|
||||||
|
store.activeChapter ? store.bookmarks.find(b => b.chapterId === store.activeChapter!.id) : undefined
|
||||||
|
);
|
||||||
|
const isBookmarked = $derived(!!currentBookmark);
|
||||||
|
|
||||||
|
// In longstrip, always track the visually active chapter for history/RPC —
|
||||||
|
// autoNext only controls nav-button behavior, not which chapter we attribute
|
||||||
|
// progress to. Without this, scrolling into ch48 while ch47 is activeChapter
|
||||||
|
// would record page 28 of ch48 as page 28 of ch47.
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && autoNext && visibleChapterId
|
style === "longstrip" && visibleChapterId
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||||
: store.activeChapter
|
: store.activeChapter
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Discord RPC ──────────────────────────────────────────────────────────────
|
||||||
|
// displayChapter already handles both single/double (store.activeChapter) and
|
||||||
|
// longstrip auto-next (visibleChapterId) — so reacting to it here means RPC
|
||||||
|
// updates on every chapter transition regardless of reading mode.
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const chapter = displayChapter;
|
||||||
|
const manga = store.activeManga;
|
||||||
|
if (store.settings.discordRpc && chapter && manga) {
|
||||||
|
setReading(manga, chapter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const adjacent = $derived.by(() => {
|
const adjacent = $derived.by(() => {
|
||||||
const ref = displayChapter ?? store.activeChapter;
|
const ref = displayChapter ?? store.activeChapter;
|
||||||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||||||
@@ -148,7 +244,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const visibleChunkLastPage = $derived.by(() => {
|
const visibleChunkLastPage = $derived.by(() => {
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
if (style !== "longstrip") return lastPage;
|
||||||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
const chunk = stripChapters.find(c => c.chapterId === chId);
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunk?.urls.length ?? lastPage;
|
||||||
@@ -167,7 +263,7 @@
|
|||||||
|
|
||||||
const stripToRender = $derived(
|
const stripToRender = $derived(
|
||||||
style === "longstrip"
|
style === "longstrip"
|
||||||
? (autoNext && stripChapters.length > 0
|
? (stripChapters.length > 0
|
||||||
? stripChapters
|
? stripChapters
|
||||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
||||||
: []
|
: []
|
||||||
@@ -199,14 +295,20 @@
|
|||||||
error = null;
|
error = null;
|
||||||
pageGroups = [];
|
pageGroups = [];
|
||||||
pageReady = false;
|
pageReady = false;
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
visibleChapterId = null;
|
|
||||||
store.pageUrls = [];
|
store.pageUrls = [];
|
||||||
|
// Snapshot the resume page BEFORE resetting — openReader already set
|
||||||
|
// store.pageNumber to the saved position, but we must not clobber it here.
|
||||||
|
// We reset to 1 as a safe interim value while pages load, then restore
|
||||||
|
// after the fetch completes so the viewer jumps to the right page.
|
||||||
|
const resumeTo = store.pageNumber > 1 ? store.pageNumber : 1;
|
||||||
store.pageNumber = 1;
|
store.pageNumber = 1;
|
||||||
try {
|
try {
|
||||||
const urls = await fetchPages(id, ctrl.signal);
|
const urls = await fetchPages(id, ctrl.signal);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
store.pageUrls = urls;
|
store.pageUrls = urls;
|
||||||
|
// Clamp the resume page to actual page count (in case history is stale).
|
||||||
|
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
pageReady = true;
|
pageReady = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -217,31 +319,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
||||||
// Runs when a chapter finishes loading in longstrip mode.
|
// IMPORTANT: do NOT read store.pageNumber here — it's updated by the scroll
|
||||||
// Starts the strip with just the current chapter; appendNextChapter adds more
|
// observer on every scroll event, which would re-run this effect continuously
|
||||||
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
|
// and reset stripChapters/scroll on every pixel scrolled (the "snap" bug).
|
||||||
|
// Resume page is read from the fixed `resumePage` $state instead, which is
|
||||||
|
// captured once at component init from history and never changes.
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||||
const ch = store.activeChapter;
|
const ch = store.activeChapter;
|
||||||
const urls = store.pageUrls;
|
const urls = store.pageUrls;
|
||||||
|
// resumePage is a $state set once from history — not reactive to scroll.
|
||||||
|
const targetPg = untrack(() => resumePage);
|
||||||
appending = false;
|
appending = false;
|
||||||
if (autoNext) {
|
// Always populate stripChapters in longstrip — it's needed for infinite
|
||||||
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
// scroll appending. autoNext only controls whether the chapter header
|
||||||
visibleChapterId = ch.id;
|
// and visible-chapter tracking update as you scroll between chapters.
|
||||||
} else {
|
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||||
stripChapters = [];
|
visibleChapterId = ch.id;
|
||||||
visibleChapterId = null;
|
// Wait for Svelte to flush the new img elements into the DOM, then scroll.
|
||||||
}
|
// If resuming mid-chapter (targetPg > 1), force-load preceding images so
|
||||||
if (containerEl) containerEl.scrollTop = 0;
|
// their heights are in layout, then scrollIntoView on the target image.
|
||||||
|
tick().then(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
if (targetPg > 1) {
|
||||||
|
const chId = ch.id;
|
||||||
|
const scrollToResumePage = () => {
|
||||||
|
const target = containerEl.querySelector<HTMLImageElement>(
|
||||||
|
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
|
||||||
|
);
|
||||||
|
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
||||||
|
|
||||||
|
// Eager-load all images up to the target so their heights are known.
|
||||||
|
containerEl.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`)
|
||||||
|
.forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
||||||
|
|
||||||
|
const doScroll = () => {
|
||||||
|
target.scrollIntoView({ block: "start" });
|
||||||
|
stripResumeReady = true;
|
||||||
|
};
|
||||||
|
if (target.complete && target.naturalHeight > 0) { doScroll(); }
|
||||||
|
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
||||||
|
};
|
||||||
|
scrollToResumePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
containerEl.scrollTop = 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||||
|
|
||||||
// ─── Forward append only ──────────────────────────────────────────────────────
|
// ─── Forward append only ──────────────────────────────────────────────────────
|
||||||
// Appends the next chapter to the bottom when the user scrolls past 80%.
|
|
||||||
// No eviction, no prepend, no sliding window — chapters accumulate forward.
|
|
||||||
|
|
||||||
function appendNextChapter() {
|
function appendNextChapter() {
|
||||||
if (appending || !stripChapters.length) return;
|
if (appending || !stripChapters.length) return;
|
||||||
@@ -272,9 +402,6 @@
|
|||||||
let stripChaptersRef: StripChapter[] = [];
|
let stripChaptersRef: StripChapter[] = [];
|
||||||
$effect(() => { stripChaptersRef = stripChapters; });
|
$effect(() => { stripChaptersRef = stripChapters; });
|
||||||
|
|
||||||
let autoNextRef = false;
|
|
||||||
$effect(() => { autoNextRef = autoNext; });
|
|
||||||
|
|
||||||
function setupScrollTracking(): () => void {
|
function setupScrollTracking(): () => void {
|
||||||
if (!containerEl || style !== "longstrip") return () => {};
|
if (!containerEl || style !== "longstrip") return () => {};
|
||||||
|
|
||||||
@@ -300,7 +427,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activePage !== null) store.pageNumber = activePage;
|
if (activePage !== null) store.pageNumber = activePage;
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
if (activeChId && activeChId !== visibleChapterId) {
|
||||||
|
// Crossed into a new chapter — reset the previous chapter's resume
|
||||||
|
// position to page 1 so reopening it starts fresh. The history entry
|
||||||
|
// itself is kept so it still appears in the continue-reading UI.
|
||||||
|
if (visibleChapterId) resetChapterProgress(visibleChapterId);
|
||||||
|
visibleChapterId = activeChId;
|
||||||
|
}
|
||||||
|
|
||||||
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
||||||
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
||||||
@@ -315,7 +448,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onScrollAppend() {
|
function onScrollAppend() {
|
||||||
if (!autoNextRef) return;
|
// Infinite scroll always active in longstrip — autoNext only controls the
|
||||||
|
// nav-button chapter transition behavior, not scroll-triggered appending.
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.80) appendNextChapter();
|
if (pct >= 0.80) appendNextChapter();
|
||||||
}
|
}
|
||||||
@@ -399,17 +533,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Progress / history tracking ─────────────────────────────────────────────
|
// ─── Progress / history tracking ─────────────────────────────────────────────
|
||||||
// Only records history after the user has genuinely navigated (pageNumber > 1,
|
|
||||||
// or scrolled past page 1 in longstrip). This prevents the chapter-open event
|
|
||||||
// from writing "page 1" as the last-read position, which caused the history to
|
|
||||||
// always show the chapter you started on rather than where you left off.
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Use displayChapter, not store.activeChapter — in longstrip with autoNext,
|
|
||||||
// store.activeChapter stays as the chapter you *opened* (e.g. ch61) while
|
|
||||||
// displayChapter tracks visibleChapterId (the chapter actually on screen).
|
|
||||||
// Using store.activeChapter here caused every history write to stamp ch61
|
|
||||||
// even when the user had scrolled all the way to ch72.
|
|
||||||
const ch = displayChapter ?? store.activeChapter;
|
const ch = displayChapter ?? store.activeChapter;
|
||||||
if (ch && lastPage && store.activeManga) {
|
if (ch && lastPage && store.activeManga) {
|
||||||
const chapterId = ch.id;
|
const chapterId = ch.id;
|
||||||
@@ -420,11 +545,9 @@
|
|||||||
const pageNum = store.pageNumber;
|
const pageNum = store.pageNumber;
|
||||||
const atLast = store.pageNumber === lastPage;
|
const atLast = store.pageNumber === lastPage;
|
||||||
|
|
||||||
// Mark that the user has moved past the initial load.
|
|
||||||
if (pageNum > 1) hasNavigated = true;
|
if (pageNum > 1) hasNavigated = true;
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
// Skip the very first page-1 write that fires on chapter load.
|
|
||||||
if (!hasNavigated) return;
|
if (!hasNavigated) return;
|
||||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
||||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
||||||
@@ -457,7 +580,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
function maybeMarkCurrentRead() {
|
||||||
const ch = store.activeChapter;
|
const ch = displayChapter ?? store.activeChapter;
|
||||||
if (ch && markOnNext) markChapterRead(ch.id);
|
if (ch && markOnNext) markChapterRead(ch.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +631,42 @@
|
|||||||
const goNext = $derived(rtl ? goBack : goForward);
|
const goNext = $derived(rtl ? goBack : goForward);
|
||||||
const goPrev = $derived(rtl ? goForward : goBack);
|
const goPrev = $derived(rtl ? goForward : goBack);
|
||||||
|
|
||||||
|
// ─── Zoom helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function clampZoom(z: number): number {
|
||||||
|
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustZoom(delta: number) {
|
||||||
|
captureZoomAnchor();
|
||||||
|
updateSettings({ readerZoom: clampZoom(zoom + delta) });
|
||||||
|
restoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
captureZoomAnchor();
|
||||||
|
updateSettings({ readerZoom: 1.0 });
|
||||||
|
restoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBookmark() {
|
||||||
|
const ch = store.activeChapter;
|
||||||
|
const manga = store.activeManga;
|
||||||
|
if (!ch || !manga) return;
|
||||||
|
if (isBookmarked) {
|
||||||
|
removeBookmark(ch.id);
|
||||||
|
} else {
|
||||||
|
addBookmark({
|
||||||
|
mangaId: manga.id,
|
||||||
|
mangaTitle: manga.title,
|
||||||
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterName: ch.name,
|
||||||
|
pageNumber: store.pageNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function cycleStyle() {
|
function cycleStyle() {
|
||||||
@@ -532,13 +691,13 @@
|
|||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!e.ctrlKey) return;
|
if (!e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
// Each wheel tick adjusts by ZOOM_STEP (5%). Larger deltaY = bigger scroll = same step.
|
||||||
|
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||||
const mW = store.settings.maxPageWidth ?? 900;
|
|
||||||
const r = store.settings.readingDirection === "rtl";
|
const r = store.settings.readingDirection === "rtl";
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -546,9 +705,9 @@
|
|||||||
if (dlOpen) { dlOpen = false; return; }
|
if (dlOpen) { dlOpen = false; return; }
|
||||||
closeReader(); return;
|
closeReader(); return;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; }
|
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
|
||||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; }
|
if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
|
||||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||||
@@ -570,6 +729,7 @@
|
|||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
@@ -592,12 +752,20 @@
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
window.addEventListener("wheel", onWheel, { passive: false });
|
||||||
containerEl?.focus({ preventScroll: true });
|
containerEl?.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
// Track the viewer's actual paint width so zoom is always relative to it.
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
containerWidth = entries[0].contentRect.width;
|
||||||
|
});
|
||||||
|
ro.observe(containerEl);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortCtrl?.abort();
|
abortCtrl?.abort();
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
window.removeEventListener("wheel", onWheel);
|
window.removeEventListener("wheel", onWheel);
|
||||||
cleanupScroll();
|
cleanupScroll();
|
||||||
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -626,16 +794,37 @@
|
|||||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
||||||
<span class="mode-label">{fitLabel}</span>
|
<span class="mode-label">{fitLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Zoom controls ────────────────────────────────────────────────────── -->
|
||||||
<div class="zoom-wrap">
|
<div class="zoom-wrap">
|
||||||
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button>
|
<div class="zoom-inline">
|
||||||
|
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||||
|
<MagnifyingGlassMinus size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
<button class="zoom-pct-btn" onclick={() => zoomOpen = !zoomOpen} title="Click to adjust zoom">
|
||||||
|
{zoomPct}%
|
||||||
|
</button>
|
||||||
|
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||||
|
<MagnifyingGlassPlus size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{#if zoomOpen}
|
{#if zoomOpen}
|
||||||
<div class="zoom-popover">
|
<div class="zoom-popover">
|
||||||
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW}
|
<div class="zoom-slider-row">
|
||||||
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} />
|
<input type="range" class="zoom-slider" min={10} max={400} step={5} value={zoomPct}
|
||||||
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button>
|
oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
|
||||||
|
</div>
|
||||||
|
<div class="zoom-presets">
|
||||||
|
{#each [50, 75, 100, 125, 150, 200] as pct}
|
||||||
|
<button class="zoom-preset" class:active={zoomPct === pct}
|
||||||
|
onclick={() => { captureZoomAnchor(); updateSettings({ readerZoom: pct / 100 }); restoreZoomAnchor(); }}>{pct}%</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
||||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -667,13 +856,19 @@
|
|||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
class="viewer"
|
class="viewer"
|
||||||
class:strip={style === "longstrip"}
|
class:strip={style === "longstrip"}
|
||||||
style="--max-page-width:{maxW}px"
|
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={handleTap}
|
onclick={handleTap}
|
||||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||||
>
|
>
|
||||||
|
{#if showResumeBanner}
|
||||||
|
<div class="resume-banner" role="status">
|
||||||
|
<span>Resumed from page {resumePage}</span>
|
||||||
|
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -711,11 +906,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
<div class="bottombar" class:hidden={!uiVisible}>
|
||||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
<button class="nav-btn" onclick={goBack} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
{#if rtl}<ArrowRight size={13} weight="light" />{:else}<ArrowLeft size={13} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
<button class="nav-btn" onclick={goForward} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||||||
<ArrowRight size={13} weight="light" />
|
{#if rtl}<ArrowLeft size={13} weight="light" />{:else}<ArrowRight size={13} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -762,6 +957,7 @@
|
|||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||||
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); }
|
.ch-sep { color: var(--text-faint); }
|
||||||
@@ -771,25 +967,51 @@
|
|||||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.mode-label { text-transform: capitalize; }
|
.mode-label { text-transform: capitalize; }
|
||||||
|
|
||||||
|
/* ── Zoom controls ───────────────────────────────────────────────────────── */
|
||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||||
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
|
||||||
|
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||||||
|
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
|
.zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; }
|
||||||
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
.zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
.zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Viewer ──────────────────────────────────────────────────────────────── */
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
.viewer:focus { outline: none; }
|
.viewer:focus { outline: none; }
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
||||||
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
|
|
||||||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
/*
|
||||||
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
* Fit modes — all constrain within --effective-width (the zoom-adjusted
|
||||||
|
* container width). effectiveWidth is set as a CSS variable on .viewer
|
||||||
|
* so every fit class automatically respects the current zoom level.
|
||||||
|
*
|
||||||
|
* fit-width : fills up to effectiveWidth, never wider
|
||||||
|
* fit-height : constrained to viewport height; never taller, never wider than effectiveWidth
|
||||||
|
* fit-screen : fits within both axes (contain); never wider than effectiveWidth
|
||||||
|
* fit-original : natural image size, no constraint
|
||||||
|
*/
|
||||||
|
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
|
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
|
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||||
.fit-original { max-width: none; width: auto; height: auto; }
|
.fit-original { max-width: none; width: auto; height: auto; }
|
||||||
|
|
||||||
.strip-gap { margin-bottom: 8px; }
|
.strip-gap { margin-bottom: 8px; }
|
||||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; }
|
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
||||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||||
.gap-left { margin-right: 2px; }
|
.gap-left { margin-right: 2px; }
|
||||||
.gap-right { margin-left: 2px; }
|
.gap-right { margin-left: 2px; }
|
||||||
@@ -812,5 +1034,25 @@
|
|||||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
||||||
|
/* ── Resume banner ───────────────────────────────────────────────────────── */
|
||||||
|
.resume-banner {
|
||||||
|
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg); padding: 6px var(--sp-3);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary); z-index: 20;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
animation: scaleIn 0.15s ease both;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.resume-dismiss {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
font-size: 9px; color: var(--text-faint);
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,11 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
let chapters: Chapter[] = $state([]);
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
let folderOpen = $state(false);
|
let folderOpen = $state(false);
|
||||||
let newFolderName = $state("");
|
let newFolderName = $state("");
|
||||||
let creatingFolder = $state(false);
|
let creatingFolder = $state(false);
|
||||||
|
let allCategories: Category[] = $state([]);
|
||||||
|
let mangaCategories: Category[] = $state([]);
|
||||||
|
let catsLoading: boolean = $state(false);
|
||||||
let queueingAll = $state(false);
|
let queueingAll = $state(false);
|
||||||
let fetchError: string|null = $state(null);
|
let fetchError: string|null = $state(null);
|
||||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
||||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
||||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||||
|
|
||||||
const continueChapter = $derived.by(() => {
|
const continueChapter = $derived.by(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
@@ -90,7 +93,7 @@
|
|||||||
return { ch: chapters[0], label: "Read again" };
|
return { ch: chapters[0], label: "Read again" };
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
||||||
|
|
||||||
async function load(id: number) {
|
async function load(id: number) {
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort(); chapterAbort?.abort();
|
||||||
@@ -171,11 +174,55 @@
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderCreate() {
|
function loadCategories(mangaId: number) {
|
||||||
|
catsLoading = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => {
|
||||||
|
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||||
|
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { catsLoading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||||
|
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
// Sync local mangaCategories state after the mutation
|
||||||
|
if (chaps.length) {
|
||||||
|
const allRead = chaps.every(c => c.isRead);
|
||||||
|
const completed = allCategories.find(c => c.name === "Completed");
|
||||||
|
if (completed) {
|
||||||
|
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||||
|
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||||
|
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCategory(cat: Category) {
|
||||||
|
if (!store.previewManga) return;
|
||||||
|
const mangaId = store.previewManga.id;
|
||||||
|
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||||
|
mangaId,
|
||||||
|
addTo: inCat ? [] : [cat.id],
|
||||||
|
removeFrom: inCat ? [cat.id] : [],
|
||||||
|
}).catch(console.error);
|
||||||
|
mangaCategories = inCat
|
||||||
|
? mangaCategories.filter(c => c.id !== cat.id)
|
||||||
|
: [...mangaCategories, cat];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolderCreate() {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
if (!name || !store.previewManga) return;
|
if (!name || !store.previewManga) return;
|
||||||
const id = addFolder(name);
|
try {
|
||||||
assignMangaToFolder(id, store.previewManga.id);
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
allCategories = [...allCategories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
mangaCategories = [...mangaCategories, cat];
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
newFolderName = ""; creatingFolder = false;
|
newFolderName = ""; creatingFolder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +272,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if folderOpen}
|
{#if folderOpen}
|
||||||
<div class="folder-menu">
|
<div class="folder-menu">
|
||||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
{#if catsLoading}
|
||||||
{#each store.settings.folders as f}
|
<p class="folder-empty">Loading…</p>
|
||||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
{:else if allCategories.length === 0 && !creatingFolder}
|
||||||
<button class="folder-item" class:folder-item-on={isIn}
|
<p class="folder-empty">No folders yet</p>
|
||||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
{/if}
|
||||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
{#each allCategories as cat}
|
||||||
|
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||||
|
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
|
||||||
|
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="folder-divider"></div>
|
<div class="folder-divider"></div>
|
||||||
@@ -306,7 +356,7 @@
|
|||||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
<Play size={12} weight="fill" />{continueChapter.label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||||
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga } from "../../lib/types";
|
import type { Manga, Category } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||||
|
|
||||||
let mangas: Manga[] = [];
|
let mangas: Manga[] = $state([]);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let page = 1;
|
let page = $state(1);
|
||||||
let hasNextPage = false;
|
let hasNextPage = $state(false);
|
||||||
let browseType: BrowseType = "POPULAR";
|
let browseType: BrowseType = $state("POPULAR");
|
||||||
let search = "";
|
let search = $state("");
|
||||||
let searchInput = "";
|
let searchInput = $state("");
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||||
if (!$store.activeSource) return;
|
if (!store.activeSource) return;
|
||||||
loading = true; mangas = [];
|
loading = true; mangas = [];
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
|
FETCH_SOURCE_MANGA, { source: store.activeSource.id, type, page: p, query: q || null }
|
||||||
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => loading = false);
|
.finally(() => loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($store.activeSource) fetchMangas(browseType, page, search);
|
$effect(() => { if (store.activeSource) fetchMangas(browseType, page, search); });
|
||||||
|
|
||||||
function submitSearch() {
|
function submitSearch() {
|
||||||
search = searchInput.trim();
|
search = searchInput.trim();
|
||||||
@@ -40,38 +42,58 @@
|
|||||||
browseType = mode; search = ""; searchInput = ""; page = 1;
|
browseType = mode; search = ""; searchInput = ""; page = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||||
.catch(console.error) },
|
.catch(console.error) },
|
||||||
...($store.settings.folders.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...$store.settings.folders.map((f): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
|
}},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $store.activeSource}
|
{#if store.activeSource}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" on:click={() => store.activeSource.set(null)}>
|
<button class="back" onclick={() => setActiveSource(null)}>
|
||||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="source-name">{$store.activeSource.displayName}</span>
|
<span class="source-name">{store.activeSource.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
||||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
|
||||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -80,7 +102,7 @@
|
|||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,8 +117,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each mangas as m (m.id)}
|
{#each mangas as m (m.id)}
|
||||||
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
|
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||||
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||||
@@ -109,11 +131,11 @@
|
|||||||
|
|
||||||
{#if !loading && (page > 1 || hasNextPage)}
|
{#if !loading && (page > 1 || hasNextPage)}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||||
<Prev size={13} weight="light" /> Prev
|
<Prev size={13} weight="light" /> Prev
|
||||||
</button>
|
</button>
|
||||||
<span class="page-num">{page}</span>
|
<span class="page-num">{page}</span>
|
||||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
|
||||||
Next <Next size={13} weight="light" />
|
Next <Next size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,4 +180,5 @@
|
|||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
import { GET_SOURCES } from "../../lib/queries";
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
let search = $state("");
|
let search = $state("");
|
||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => { sources = d.sources.nodes; })
|
.then((d) => { sources = d.sources.nodes; })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export const CACHE_GROUPS = {
|
|||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
|
CATEGORIES: "categories",
|
||||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
POPULAR: "popular",
|
POPULAR: "popular",
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
|
||||||
|
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
|
||||||
|
import type { Manga, Chapter } from "./types";
|
||||||
|
|
||||||
|
const APP_ID = "1487894643613106298";
|
||||||
|
const FALLBACK_IMAGE = "moku_logo";
|
||||||
|
|
||||||
|
let sessionStart: number | null = null;
|
||||||
|
|
||||||
|
function isPublicUrl(url: string | null | undefined): boolean {
|
||||||
|
return typeof url === "string" && url.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCoverImage(manga: Manga): string {
|
||||||
|
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trunc(s: string, max = 128): string {
|
||||||
|
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChapter(chapter: Chapter): string {
|
||||||
|
const n = chapter.chapterNumber;
|
||||||
|
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimestamps(): Timestamps {
|
||||||
|
return new Timestamps(sessionStart ?? Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTONS = [
|
||||||
|
new Button("GitHub", "https://github.com/Youwes09/Moku"),
|
||||||
|
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function initRpc(): Promise<void> {
|
||||||
|
sessionStart = Date.now();
|
||||||
|
await start(APP_ID).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||||
|
const assets = new Assets()
|
||||||
|
.setLargeImage(resolveCoverImage(manga))
|
||||||
|
.setLargeText(trunc(manga.title))
|
||||||
|
.setSmallImage(FALLBACK_IMAGE)
|
||||||
|
.setSmallText("Moku");
|
||||||
|
|
||||||
|
const activity = new Activity()
|
||||||
|
.setDetails(trunc(manga.title))
|
||||||
|
.setState(`${formatChapter(chapter)} · Reading`)
|
||||||
|
.setAssets(assets)
|
||||||
|
.setTimestamps(getTimestamps());
|
||||||
|
activity.setButton(BUTTONS);
|
||||||
|
|
||||||
|
await setActivity(activity).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIdle(): Promise<void> {
|
||||||
|
const assets = new Assets()
|
||||||
|
.setLargeImage(FALLBACK_IMAGE)
|
||||||
|
.setLargeText("Moku");
|
||||||
|
|
||||||
|
const activity = new Activity()
|
||||||
|
.setDetails("Browsing")
|
||||||
|
.setAssets(assets)
|
||||||
|
.setTimestamps(getTimestamps());
|
||||||
|
activity.setButton(BUTTONS);
|
||||||
|
|
||||||
|
await setActivity(activity).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearReading(): Promise<void> {
|
||||||
|
await clearActivity().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyRpc(): Promise<void> {
|
||||||
|
sessionStart = null;
|
||||||
|
await stop().catch(() => {});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export interface Keybinds {
|
|||||||
togglePageStyle: string;
|
togglePageStyle: string;
|
||||||
toggleFullscreen: string;
|
toggleFullscreen: string;
|
||||||
openSettings: string;
|
openSettings: string;
|
||||||
|
toggleBookmark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
@@ -26,6 +27,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
|||||||
togglePageStyle: "q",
|
togglePageStyle: "q",
|
||||||
toggleFullscreen: "f",
|
toggleFullscreen: "f",
|
||||||
openSettings: "o",
|
openSettings: "o",
|
||||||
|
toggleBookmark: "m",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
@@ -40,6 +42,7 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
|||||||
togglePageStyle: "Toggle page style",
|
togglePageStyle: "Toggle page style",
|
||||||
toggleFullscreen: "Toggle fullscreen",
|
toggleFullscreen: "Toggle fullscreen",
|
||||||
openSettings: "Open settings",
|
openSettings: "Open settings",
|
||||||
|
toggleBookmark: "Toggle bookmark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function eventToKeybind(e: KeyboardEvent): string {
|
export function eventToKeybind(e: KeyboardEvent): string {
|
||||||
|
|||||||
@@ -191,6 +191,95 @@ export const GET_DOWNLOADS_PATH = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ── Categories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
thumbnailUrl
|
||||||
|
inLibrary
|
||||||
|
downloadCount
|
||||||
|
unreadCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_CATEGORY = `
|
||||||
|
mutation CreateCategory($name: String!) {
|
||||||
|
createCategory(input: { name: $name }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY = `
|
||||||
|
mutation UpdateCategory($id: Int!, $name: String) {
|
||||||
|
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY = `
|
||||||
|
mutation DeleteCategory($id: Int!) {
|
||||||
|
deleteCategory(input: { categoryId: $id }) {
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_ORDER = `
|
||||||
|
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||||
|
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||||
|
categories {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
order
|
||||||
|
default
|
||||||
|
includeInUpdate
|
||||||
|
includeInDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA_CATEGORIES = `
|
||||||
|
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
manga {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// ── Downloads ─────────────────────────────────────────────────────────────────
|
// ── Downloads ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GET_DOWNLOAD_STATUS = `
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
default: boolean;
|
||||||
|
includeInUpdate: string;
|
||||||
|
includeInDownload: string;
|
||||||
|
mangas?: {
|
||||||
|
nodes: Manga[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface Manga {
|
export interface Manga {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -5,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,82 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return clsx(inputs);
|
return clsx(inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default substrings used when no user-configured list is available.
|
||||||
|
* The Settings > Content tab lets users add/remove entries from this list,
|
||||||
|
* which is stored as settings.nsfwFilteredTags.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_NSFW_TAGS = [
|
||||||
|
"adult",
|
||||||
|
"mature",
|
||||||
|
"hentai",
|
||||||
|
"ecchi",
|
||||||
|
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||||
|
"pornograph", // catches "pornographic", "pornography"
|
||||||
|
"18+",
|
||||||
|
"smut",
|
||||||
|
"lemon",
|
||||||
|
"explicit",
|
||||||
|
"sexual violence",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the manga carries at least one genre tag matching any of
|
||||||
|
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
||||||
|
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
||||||
|
*/
|
||||||
|
export function isNsfwManga(
|
||||||
|
manga: { genre?: string[] | null },
|
||||||
|
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||||
|
): boolean {
|
||||||
|
return (manga.genre ?? []).some((g) => {
|
||||||
|
const normalized = g.toLowerCase().trim();
|
||||||
|
return tags.some((sub) => normalized.includes(sub));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single authoritative NSFW gate used by all views.
|
||||||
|
*
|
||||||
|
* Returns true when the manga should be HIDDEN. Checks in order:
|
||||||
|
* 1. showNsfw disabled globally → skip everything, hide by source flag or genre match.
|
||||||
|
* 2. Source is in blockedSourceIds → always hide regardless of showNsfw.
|
||||||
|
* 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply).
|
||||||
|
* 4. Source isNsfw flag → hide unless source is allowed.
|
||||||
|
* 5. Genre tag match → hide.
|
||||||
|
*
|
||||||
|
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||||
|
*/
|
||||||
|
export function shouldHideNsfw(
|
||||||
|
manga: {
|
||||||
|
genre?: string[] | null;
|
||||||
|
source?: { id?: string; isNsfw?: boolean } | null;
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
showNsfw: boolean;
|
||||||
|
nsfwFilteredTags: string[];
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const srcId = manga.source?.id;
|
||||||
|
|
||||||
|
// Explicit block always wins, even when showNsfw is on
|
||||||
|
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||||
|
|
||||||
|
// If NSFW is globally allowed, only explicit blocks apply
|
||||||
|
if (settings.showNsfw) return false;
|
||||||
|
|
||||||
|
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
|
||||||
|
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||||
|
|
||||||
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
|
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
|||||||
+197
-118
@@ -1,4 +1,4 @@
|
|||||||
import type { Manga, Chapter, Source } from "../lib/types";
|
import type { Manga, Chapter, Category, Source } from "../lib/types";
|
||||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||||
|
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -78,7 +97,6 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
|||||||
"color-info-bg": "#121a1f",
|
"color-info-bg": "#121a1f",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COMPLETED_FOLDER_ID = "completed";
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -90,6 +108,18 @@ export interface HistoryEntry {
|
|||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookmarkEntry {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
pageNumber: number;
|
||||||
|
savedAt: number;
|
||||||
|
/** Optional user label, e.g. "before the fight scene" */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadLogEntry — append-only record of every chapter-completion event.
|
* ReadLogEntry — append-only record of every chapter-completion event.
|
||||||
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
||||||
@@ -142,19 +172,17 @@ export interface ActiveDownload {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mangaIds: number[];
|
|
||||||
showTab: boolean;
|
|
||||||
system?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Settings {
|
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;
|
||||||
@@ -164,10 +192,16 @@ export interface Settings {
|
|||||||
libraryCropCovers: boolean;
|
libraryCropCovers: boolean;
|
||||||
libraryPageSize: number;
|
libraryPageSize: number;
|
||||||
showNsfw: boolean;
|
showNsfw: boolean;
|
||||||
|
discordRpc: boolean;
|
||||||
chapterSortDir: ChapterSortDir;
|
chapterSortDir: ChapterSortDir;
|
||||||
chapterSortMode: ChapterSortMode;
|
chapterSortMode: ChapterSortMode;
|
||||||
chapterPageSize: number;
|
chapterPageSize: number;
|
||||||
uiScale: number;
|
/**
|
||||||
|
* UI zoom level — unitless float multiplier applied on top of the
|
||||||
|
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
|
||||||
|
* Replaces the old `uiScale` percentage integer.
|
||||||
|
*/
|
||||||
|
uiZoom: number;
|
||||||
compactSidebar: boolean;
|
compactSidebar: boolean;
|
||||||
gpuAcceleration: boolean;
|
gpuAcceleration: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -178,7 +212,6 @@ export interface Settings {
|
|||||||
idleTimeoutMin?: number;
|
idleTimeoutMin?: number;
|
||||||
splashCards?: boolean;
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
|
||||||
markReadOnNext: boolean;
|
markReadOnNext: boolean;
|
||||||
readerDebounceMs: number;
|
readerDebounceMs: number;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
@@ -204,21 +237,34 @@ export interface Settings {
|
|||||||
appLockEnabled: boolean;
|
appLockEnabled: boolean;
|
||||||
appLockPin: string;
|
appLockPin: string;
|
||||||
customThemes: CustomTheme[];
|
customThemes: CustomTheme[];
|
||||||
|
hiddenCategoryIds: number[];
|
||||||
|
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
|
||||||
|
defaultLibraryCategoryId: number | null;
|
||||||
|
/**
|
||||||
|
* Content filtering — managed via the Content tab in Settings.
|
||||||
|
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive).
|
||||||
|
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true.
|
||||||
|
* nsfwBlockedSourceIds: sources always blocked regardless of tag content.
|
||||||
|
*/
|
||||||
|
nsfwFilteredTags: string[];
|
||||||
|
nsfwAllowedSourceIds: string[];
|
||||||
|
nsfwBlockedSourceIds: string[];
|
||||||
|
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
|
||||||
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
|
// Legacy fields kept for migration reads only — never written after v3.
|
||||||
|
/** @deprecated use readerZoom */
|
||||||
|
maxPageWidth?: number;
|
||||||
|
/** @deprecated use uiZoom */
|
||||||
|
uiScale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
|
||||||
id: COMPLETED_FOLDER_ID,
|
|
||||||
name: "Completed",
|
|
||||||
mangaIds: [],
|
|
||||||
showTab: true,
|
|
||||||
system: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
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,
|
||||||
@@ -228,10 +274,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
libraryCropCovers: true,
|
libraryCropCovers: true,
|
||||||
libraryPageSize: 48,
|
libraryPageSize: 48,
|
||||||
showNsfw: false,
|
showNsfw: false,
|
||||||
|
discordRpc: false,
|
||||||
chapterSortDir: "desc",
|
chapterSortDir: "desc",
|
||||||
chapterSortMode: "source",
|
chapterSortMode: "source",
|
||||||
chapterPageSize: 25,
|
chapterPageSize: 25,
|
||||||
uiScale: 100,
|
uiZoom: 1.0,
|
||||||
compactSidebar: false,
|
compactSidebar: false,
|
||||||
gpuAcceleration: true,
|
gpuAcceleration: true,
|
||||||
serverUrl: "http://localhost:4567",
|
serverUrl: "http://localhost:4567",
|
||||||
@@ -242,7 +289,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
idleTimeoutMin: 5,
|
idleTimeoutMin: 5,
|
||||||
splashCards: true,
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
|
||||||
markReadOnNext: true,
|
markReadOnNext: true,
|
||||||
readerDebounceMs: 120,
|
readerDebounceMs: 120,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
@@ -268,16 +314,25 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
appLockEnabled: false,
|
appLockEnabled: false,
|
||||||
appLockPin: "",
|
appLockPin: "",
|
||||||
customThemes: [],
|
customThemes: [],
|
||||||
|
hiddenCategoryIds: [],
|
||||||
|
defaultLibraryCategoryId: null,
|
||||||
|
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||||
|
nsfwAllowedSourceIds: [],
|
||||||
|
nsfwBlockedSourceIds: [],
|
||||||
|
libraryTabSort: {},
|
||||||
|
libraryTabStatus: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STORE_VERSION = 2;
|
const STORE_VERSION = 3;
|
||||||
|
|
||||||
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
|
||||||
// Add a key here whenever its default changes meaning between releases.
|
// Add a key here whenever its default changes meaning between releases.
|
||||||
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
const RESET_ON_UPGRADE: (keyof Settings)[] = [
|
||||||
"serverBinary",
|
"serverBinary",
|
||||||
|
"readerZoom",
|
||||||
|
"uiZoom",
|
||||||
];
|
];
|
||||||
|
|
||||||
function loadPersisted(): any {
|
function loadPersisted(): any {
|
||||||
@@ -318,20 +373,19 @@ const saved = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function mergeSettings(saved: any): Settings {
|
function mergeSettings(saved: any): Settings {
|
||||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
|
||||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
const completedFolder: Folder = existingCompleted
|
|
||||||
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
|
||||||
: COMPLETED_FOLDER_DEFAULT;
|
|
||||||
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...saved?.settings,
|
...saved?.settings,
|
||||||
folders: [completedFolder, ...otherFolders],
|
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
|
||||||
customThemes: saved?.settings?.customThemes ?? [],
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
|
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||||
|
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||||
|
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||||
|
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +404,7 @@ const genId = () => Math.random().toString(36).slice(2, 10);
|
|||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||||
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
libraryFilter: LibraryFilter = $state("library");
|
||||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||||
/**
|
/**
|
||||||
* readLog — append-only, never deduped. Every chapter completion/progress
|
* readLog — append-only, never deduped. Every chapter completion/progress
|
||||||
@@ -358,6 +412,11 @@ class Store {
|
|||||||
* Capped at 5 000 entries; oldest are trimmed first.
|
* Capped at 5 000 entries; oldest are trimmed first.
|
||||||
*/
|
*/
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
|
/**
|
||||||
|
* bookmarks — user-placed markers at a specific page in a chapter.
|
||||||
|
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
|
||||||
|
*/
|
||||||
|
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||||
settings: Settings = $state(mergeSettings(saved));
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
|
|
||||||
@@ -383,6 +442,12 @@ class Store {
|
|||||||
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
||||||
isFullscreen: boolean = $state(false);
|
isFullscreen: boolean = $state(false);
|
||||||
|
|
||||||
|
// ── Shared category list ──────────────────────────────────────────────────
|
||||||
|
// Single source of truth for the category list, shared between Library and
|
||||||
|
// Settings. Library owns fetching; Settings reads and mutates in-place.
|
||||||
|
// No pub/sub or guard flags needed — both components share this $state ref.
|
||||||
|
categories: Category[] = $state([]);
|
||||||
|
|
||||||
// ── Discover session cache ────────────────────────────────────────────────
|
// ── Discover session cache ────────────────────────────────────────────────
|
||||||
// Survives navigation within a session but is never persisted to localStorage.
|
// Survives navigation within a session but is never persisted to localStorage.
|
||||||
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
||||||
@@ -397,16 +462,26 @@ class Store {
|
|||||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||||
$effect(() => { persist({ history: this.history }); });
|
$effect(() => { persist({ history: this.history }); });
|
||||||
$effect(() => { persist({ readLog: this.readLog }); });
|
$effect(() => { persist({ readLog: this.readLog }); });
|
||||||
|
$effect(() => { persist({ bookmarks: this.bookmarks }); });
|
||||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||||
$effect(() => { persist({ settings: this.settings }); });
|
$effect(() => { persist({ settings: this.settings }); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||||
|
// Always set activeManga when provided so the Reader has full manga
|
||||||
|
// context for Discord RPC (setReading) and any other manga-aware logic.
|
||||||
|
// Callers that already set store.activeManga directly may omit this arg.
|
||||||
|
if (manga) this.activeManga = manga;
|
||||||
this.activeChapter = chapter;
|
this.activeChapter = chapter;
|
||||||
this.activeChapterList = chapterList;
|
this.activeChapterList = chapterList;
|
||||||
this.pageUrls = [];
|
this.pageUrls = [];
|
||||||
this.pageNumber = 1;
|
// Resume from the last saved position if history has one for this chapter.
|
||||||
|
// history[n].pageNumber is kept up-to-date by the progress $effect in
|
||||||
|
// Reader.svelte as the user pages through, so this is always the last page
|
||||||
|
// they were on — not just the page they started from.
|
||||||
|
const saved = this.history.find(h => h.chapterId === chapter.id);
|
||||||
|
this.pageNumber = (saved && saved.pageNumber > 1) ? saved.pageNumber : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReader() {
|
closeReader() {
|
||||||
@@ -484,7 +559,38 @@ class Store {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a bookmark for the given chapter/page. Only one bookmark
|
||||||
|
* per chapter is kept — adding a second one replaces the first.
|
||||||
|
*/
|
||||||
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||||
|
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
|
||||||
|
this.bookmarks = [
|
||||||
|
bookmark,
|
||||||
|
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||||
|
].slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(chapterId: number) {
|
||||||
|
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBookmark(chapterId: number): BookmarkEntry | undefined {
|
||||||
|
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
clearHistory() { this.history = []; this.readLog = []; }
|
clearHistory() { this.history = []; this.readLog = []; }
|
||||||
|
/**
|
||||||
|
* Reset the resume position for a chapter back to page 1.
|
||||||
|
* Called when the user scrolls past a chapter boundary in longstrip — the
|
||||||
|
* chapter still appears in history (for the continue-reading UI), but
|
||||||
|
* reopening it will start from page 1 instead of resuming mid-chapter.
|
||||||
|
*/
|
||||||
|
resetChapterProgress(chapterId: number) {
|
||||||
|
this.history = this.history.map(h =>
|
||||||
|
h.chapterId === chapterId ? { ...h, pageNumber: 1 } : h
|
||||||
|
);
|
||||||
|
}
|
||||||
clearHistoryForManga(mangaId: number) {
|
clearHistoryForManga(mangaId: number) {
|
||||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||||
@@ -504,31 +610,9 @@ class Store {
|
|||||||
this.history = [];
|
this.history = [];
|
||||||
this.readLog = [];
|
this.readLog = [];
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
markMangaCompleted(mangaId: number) {
|
|
||||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
if (!folder) return;
|
|
||||||
if (!folder.mangaIds.includes(mangaId))
|
|
||||||
folder.mangaIds = [...folder.mangaIds, mangaId];
|
|
||||||
}
|
|
||||||
|
|
||||||
unmarkMangaCompleted(mangaId: number) {
|
|
||||||
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
if (!folder) return;
|
|
||||||
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
isCompleted(mangaId: number): boolean {
|
|
||||||
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
|
||||||
if (!chapters.length) return;
|
|
||||||
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
|
|
||||||
else this.unmarkMangaCompleted(mangaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
linkManga(idA: number, idB: number) {
|
linkManga(idA: number, idB: number) {
|
||||||
if (idA === idB) return;
|
if (idA === idB) return;
|
||||||
@@ -560,6 +644,7 @@ class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||||
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||||
@@ -575,53 +660,6 @@ class Store {
|
|||||||
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||||
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||||
|
|
||||||
addFolder(name: string): string {
|
|
||||||
const id = genId();
|
|
||||||
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFolder(id: string) {
|
|
||||||
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
|
|
||||||
}
|
|
||||||
|
|
||||||
renameFolder(id: string, name: string) {
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleFolderTab(id: string) {
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
assignMangaToFolder(folderId: string, mangaId: number) {
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
folders: this.settings.folders.map(f =>
|
|
||||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
|
||||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMangaFromFolder(folderId: string, mangaId: number) {
|
|
||||||
this.settings = {
|
|
||||||
...this.settings,
|
|
||||||
folders: this.settings.folders.map(f =>
|
|
||||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getMangaFolders(mangaId: number): Folder[] {
|
|
||||||
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCustomTheme(theme: CustomTheme) {
|
saveCustomTheme(theme: CustomTheme) {
|
||||||
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
|
||||||
@@ -637,6 +675,42 @@ class Store {
|
|||||||
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-assign or remove the "Completed" category for a manga based on
|
||||||
|
* whether all chapters are read. Pass the `gql` executor to avoid a
|
||||||
|
* circular import between state.svelte.ts and client.ts.
|
||||||
|
*
|
||||||
|
* Call after any batch mark-read/unread operation.
|
||||||
|
*/
|
||||||
|
async checkAndMarkCompleted(
|
||||||
|
mangaId: number,
|
||||||
|
chaps: Chapter[],
|
||||||
|
categories: Category[],
|
||||||
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
UPDATE_MANGA_CATEGORIES: string,
|
||||||
|
UPDATE_MANGA?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!chaps.length) return;
|
||||||
|
const allRead = chaps.every(c => c.isRead);
|
||||||
|
const completed = categories.find(c => c.name === "Completed");
|
||||||
|
if (!completed) return;
|
||||||
|
if (allRead) {
|
||||||
|
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
|
||||||
|
// Ensure the manga is in the library so it shows up in the Saved tab
|
||||||
|
if (UPDATE_MANGA) {
|
||||||
|
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [], removeFrom: [completed.id] }).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHiddenCategory(id: number) {
|
||||||
|
const ids = this.settings.hiddenCategoryIds ?? [];
|
||||||
|
const next = ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id];
|
||||||
|
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
||||||
|
}
|
||||||
|
|
||||||
clearDiscoverCache() {
|
clearDiscoverCache() {
|
||||||
this.discoverCache = new Map();
|
this.discoverCache = new Map();
|
||||||
this.discoverLibraryIds = new Set();
|
this.discoverLibraryIds = new Set();
|
||||||
@@ -648,22 +722,20 @@ export const store = new Store();
|
|||||||
|
|
||||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
||||||
|
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||||
export function closeReader() { store.closeReader(); }
|
export function closeReader() { store.closeReader(); }
|
||||||
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||||
export function clearHistory() { store.clearHistory(); }
|
export function clearHistory() { store.clearHistory(); }
|
||||||
|
export function resetChapterProgress(chapterId: number) { store.resetChapterProgress(chapterId); }
|
||||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
export function wipeAllData() { store.wipeAllData(); }
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
|
|
||||||
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
|
|
||||||
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
|
|
||||||
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
|
|
||||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||||
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||||
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||||
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||||
|
export function setCategories(cats: Category[]) { store.setCategories(cats); }
|
||||||
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
@@ -678,13 +750,20 @@ export function setLibraryTagFilter(next: string[]) { store
|
|||||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function addFolder(name: string) { return store.addFolder(name); }
|
|
||||||
export function removeFolder(id: string) { store.removeFolder(id); }
|
|
||||||
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
|
|
||||||
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
|
|
||||||
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
|
||||||
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
|
||||||
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
|
||||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||||
|
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||||
|
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||||
|
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||||
|
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||||
|
export async function checkAndMarkCompleted(
|
||||||
|
mangaId: number,
|
||||||
|
chaps: Chapter[],
|
||||||
|
categories: Category[],
|
||||||
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
UPDATE_MANGA_CATEGORIES: string,
|
||||||
|
UPDATE_MANGA?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user