Compare commits

..

11 Commits

Author SHA1 Message Date
Youwes09 8aa2dc2547 Chore: Prepare for Version 0.6.0 2026-03-29 15:47:12 -05:00
Youwes09 0a11fe3982 Feat: Discord RPC 2026-03-29 15:38:39 -05:00
Youwes09 f6786def87 Fix: SeriesDetail passing Incorrect Args to Reader 2026-03-29 14:03:28 -05:00
Youwes09 262027d9f9 Feat: Added Filtering System in Library (Request: #13) 2026-03-29 13:22:08 -05:00
Youwes09 d407359973 Fix: Added Slight Border to Mitigate Windows Tab Issue (WIP) 2026-03-29 12:58:03 -05:00
Youwes09 a77572a8d4 Fix: Constrained Home-Screen Completed & SplashScreen #15 2026-03-29 12:51:17 -05:00
Youwes09 32d2fffdc5 Fix: Zoom Issue (Bug #14) 2026-03-29 12:40:28 -05:00
Youwes09 e850cbac1e Fix: Bump Update for 0.5.1 2026-03-28 20:17:14 -05:00
Youwes09 eebd1b6446 Fix: Remove Manga Drag & Drop + Libray Move System 2026-03-28 20:09:40 -05:00
Youwes09 5ed072211b Fix: Folder State & Tabs 2026-03-28 19:36:16 -05:00
Youwes09 62e41e5f07 Fix: Reader Store Refactor (Issue #11) & Feat: Drag n Drop (WIP) 2026-03-28 17:15:01 -05:00
28 changed files with 2567 additions and 889 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 3ac5d822ac1840473333510b5e45220298702e6d1435e2cdd4b5c2f7195d764f sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+4 -3
View File
@@ -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
View File
@@ -15,7 +15,8 @@
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0", "phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1",
"tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^4.0.4",
File diff suppressed because it is too large Load Diff
+12
View File
@@ -26,6 +26,9 @@ importers:
svelte-spa-router: svelte-spa-router:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2 version: 4.0.2
tauri-plugin-drpc:
specifier: ^1.0.3
version: 1.0.3(typescript@5.9.3)
devDependencies: devDependencies:
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4 specifier: ^4.0.4
@@ -744,6 +747,11 @@ packages:
resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==}
engines: {node: '>=18'} engines: {node: '>=18'}
tauri-plugin-drpc@1.0.3:
resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==}
peerDependencies:
typescript: ^5.0.0
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -1364,6 +1372,10 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
zimmerframe: 1.1.4 zimmerframe: 1.1.4
tauri-plugin-drpc@1.0.3(typescript@5.9.3):
dependencies:
typescript: 5.9.3
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
+53 -14
View File
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"fnv", "fnv",
"uuid", "uuid 1.23.0",
] ]
[[package]] [[package]]
@@ -1752,9 +1752,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
version = "0.7.11" version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.5.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",
+2 -1
View File
@@ -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
+7 -1
View File
@@ -32,6 +32,12 @@
"process:default", "process:default",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch" "http:allow-fetch",
"drpc:default",
"drpc:allow-is-running",
"drpc:allow-spawn-thread",
"drpc:allow-destroy-thread",
"drpc:allow-set-activity",
"drpc:allow-clear-activity"
] ]
} }
+40 -85
View File
@@ -104,14 +104,14 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command] #[tauri::command]
fn get_platform_ui_scale() -> f64 { fn get_platform_ui_scale(window: tauri::Window) -> f64 {
#[cfg(target_os = "windows")] window.scale_factor().unwrap_or(1.0)
return 1.0;
#[cfg(target_os = "macos")]
return 1.0;
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return 1.5;
} }
fn kill_tachidesk(app: &tauri::AppHandle) { fn kill_tachidesk(app: &tauri::AppHandle) {
@@ -248,22 +248,7 @@ struct ServerInvocation {
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
} }
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = bundle_dir.join("jre").join("bin").join("java.exe");
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) { fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log { if let Some(f) = log {
let _ = writeln!(f, "{}", msg); let _ = writeln!(f, "{}", msg);
} }
@@ -276,81 +261,50 @@ fn resolve_server_binary(
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary)); do_log(log, &format!("[resolve] binary arg = {:?}", binary));
// 1. User-specified binary path
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
do_log(log, "[resolve] using user-supplied binary path"); let path = strip_unc(PathBuf::from(binary.trim()));
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()),
});
}
return Err(SpawnError::NotConfigured(
format!("Configured binary not found: {}", path.display()),
));
} }
let resource_dir = match app.path().resource_dir() { // 2. Bundled sidecar (Windows / Linux AppImage)
Ok(p) => {
let stripped = strip_unc(p);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
stripped
}
Err(e) => {
let msg = format!("resource_dir error: {e}");
do_log(log, &format!("[resolve] ERROR: {}", msg));
return Err(SpawnError::SpawnFailed(msg));
}
};
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let resource_dir = app.path().resource_dir().unwrap_or_default();
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &candidates {
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir)); let p = resource_dir.join(name);
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar)); if p.exists() {
do_log(log, &format!("[resolve] jar exists: {}", jar.exists())); do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation {
match find_java_in_bundle(&bundle_dir, log) { bin: p.to_string_lossy().into_owned(),
Some(java) => { args: vec![],
do_log(log, &format!("[resolve] java found: {:?}", java)); working_dir: Some(resource_dir),
if jar.exists() { });
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(bundle_dir),
});
} else {
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
}
}
None => {
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
} }
} }
} }
// 3. macOS app bundle — look in MacOS/ and Resources/
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Tauri places externalBin sidecars next to the main binary in let resource_dir = app.path().resource_dir().unwrap_or_default();
// Contents/MacOS/, not in Contents/Resources/. Derive that path let macos_dir = resource_dir.parent()
// from resource_dir (Contents/Resources → Contents/MacOS). .map(|p| p.join("MacOS"))
let macos_dir = resource_dir.join("../MacOS") .unwrap_or_default();
.canonicalize()
.unwrap_or_else(|_| resource_dir.join("../MacOS"));
do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir)); let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
// Tauri strips the target triple when installing externalBin sidecars
// into Contents/MacOS/, so the binary is always just "suwayomi-server"
// at runtime. The triple-suffixed names are only needed on disk at
// build time for Tauri to pick the right arch during bundling.
let candidates = [
"suwayomi-server",
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
];
// Search MacOS/ first (correct location), then Resources/ as fallback // Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources. // for flat dev layouts where the script sits next to resources.
@@ -573,6 +527,7 @@ fn restart_app(app: tauri::AppHandle) {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_drpc::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.5.0", "version": "0.6.0",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+49 -7
View File
@@ -7,6 +7,7 @@
import { gql } from "./lib/client"; import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte"; import Layout from "./components/layout/Layout.svelte";
import Reader from "./components/reader/Reader.svelte"; import Reader from "./components/reader/Reader.svelte";
@@ -74,13 +75,23 @@
let notConfigured = $state(false); let notConfigured = $state(false);
let idle = $state(false); let idle = $state(false);
let devSplash = $state(false); let devSplash = $state(false);
let platformScale = $state(1);
// The OS/monitor DPI scale factor for the current display.
// Queried from Rust (window.scale_factor()) on mount and updated live
// whenever the window moves to a different monitor via the scaleChanged event.
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.251.5 = Windows scaled display.
let platformScale = $state(1.0);
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
// Applied to document.documentElement so the entire UI scales correctly.
function applyZoom() { function applyZoom() {
const normalized = store.settings.uiScale * platformScale; const uiZoom = store.settings.uiZoom ?? 1.5;
document.documentElement.style.zoom = `${normalized}%`; const effective = platformScale * uiZoom;
document.documentElement.style.setProperty("--ui-scale", String(normalized)); const pct = effective * 100;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`); document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -125,8 +136,9 @@
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle)); return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
}); });
// Re-apply zoom whenever uiZoom setting or platformScale changes.
$effect(() => { $effect(() => {
store.settings.uiScale; platformScale; store.settings.uiZoom; platformScale;
applyZoom(); applyZoom();
}); });
@@ -214,14 +226,25 @@
document.addEventListener("contextmenu", e => e.preventDefault()); document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true; (window as any).__mokuShowSplash = () => devSplash = true;
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1); // Fetch the real monitor scale factor from Rust (window.scale_factor()).
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom(); applyZoom();
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => { const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen(); store.isFullscreen = await win.isFullscreen();
}); });
// Re-query the scale factor when the window moves to a different monitor.
// Tauri emits this event whenever the DPI changes (e.g. dragging window
// from a 1080p display to a 4K display).
const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor;
applyZoom();
});
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") { if (err?.kind === "NotConfigured") {
@@ -240,6 +263,8 @@
return () => { return () => {
cancelProbe = true; cancelProbe = true;
unlistenResize(); unlistenResize();
unlistenScale();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
@@ -254,6 +279,23 @@
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
// When the reader closes, show idle presence.
$effect(() => {
if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle();
}
});
function handleRetry() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
+1 -1
View File
@@ -247,7 +247,7 @@
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta); const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr); ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr; const sw = stamps[i].width, sh = stamps[i].height;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
} }
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
+32 -12
View File
@@ -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, isNsfwManga } 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 (!store.settings.showNsfw && isNsfwManga(m)) 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 => store.settings.showNsfw || !isNsfwManga(m))
);
store.discoverCache.set(localKey, local); store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT)); genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
@@ -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);
}
}, },
}, },
]; ];
+34 -9
View File
@@ -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}
+29 -17
View File
@@ -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)));
@@ -206,9 +217,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 +415,7 @@
<div class="bottom-section-hd"> <div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span> <span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0} {#if completedManga.length > 0}
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button> <button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
{/if} {/if}
</div> </div>
{#if completedManga.length > 0} {#if completedManga.length > 0}
@@ -571,9 +582,10 @@
.bottom-col:last-child { padding-left: var(--sp-4); } .bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); } .bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; } .bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); } .mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; } .mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); } .mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
+768 -102
View File
@@ -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, isNsfwManga } 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: "AZ",
unreadCount: "Unread chapters",
totalChapters: "Total chapters",
recentlyAdded: "Recently added",
recentlyRead: "Recently read",
latestFetched: "Latest fetched chapter",
latestUploaded: "Latest uploaded chapter",
};
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
ALL: "All statuses",
ONGOING: "Ongoing",
COMPLETED: "Completed",
CANCELLED: "Cancelled",
HIATUS: "Hiatus",
UNKNOWN: "Unknown",
};
const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded",
"recentlyRead", "latestFetched", "latestUploaded",
];
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
];
// Per-tab reactive state — $derived so Svelte tracks changes to libraryFilter and settings
const tabSortMode = $derived(
store.settings.libraryTabSort[store.libraryFilter]?.mode ?? "az" as LibrarySortMode
);
const tabSortDir = $derived(
store.settings.libraryTabSort[store.libraryFilter]?.dir ?? "asc" as LibrarySortDir
);
const tabStatus = $derived(
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
);
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[store.libraryFilter];
const newDir = dir ?? prev?.dir ?? "asc";
updateSettings({
libraryTabSort: {
...store.settings.libraryTabSort,
[store.libraryFilter]: { mode, dir: newDir },
},
});
}
function toggleTabSortDir() {
setTabSort(tabSortMode, tabSortDir === "asc" ? "desc" : "asc");
}
function setTabStatus(status: LibraryStatusFilter) {
updateSettings({
libraryTabStatus: {
...store.settings.libraryTabStatus,
[store.libraryFilter]: status,
},
});
filterPanelOpen = false;
}
let allManga: Manga[] = $state([]);
let 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,125 @@
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; if (!store.settings.showNsfw) {
items = items.filter(m => !isNsfwManga(m));
} }
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
if (folder) { // 3. Text search
const items = folderPool.filter(m => folder.mangaIds.includes(m.id)); if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
// 4. Status filter
if (status !== "ALL") {
items = items.filter(m => {
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
return s === status;
});
} }
return [];
// 5. Sort
const recentlyReadMap = new Map<number, number>();
if (mode === "recentlyRead") {
for (const h of store.history) {
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
}
}
const sorted = [...items].sort((a, b) => {
let cmp = 0;
switch (mode) {
case "az":
cmp = a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
break;
case "unreadCount":
cmp = (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
break;
case "totalChapters":
cmp = (a.chapterCount ?? 0) - (b.chapterCount ?? 0);
break;
case "recentlyAdded":
// id is monotonically increasing on Suwayomi — higher = newer
cmp = a.id - b.id;
break;
case "recentlyRead": {
const ra = recentlyReadMap.get(a.id) ?? 0;
const rb = recentlyReadMap.get(b.id) ?? 0;
cmp = ra - rb;
break;
}
// latestFetched / latestUploaded: no per-manga date available at list level;
// fall back to id ordering so the option still does something sensible.
case "latestFetched":
case "latestUploaded":
cmp = a.id - b.id;
break;
}
return dir === "asc" ? cmp : -cmp;
});
return sorted;
})()); })());
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)))); const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
@@ -119,18 +385,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 +490,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 +663,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 +843,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 +895,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); }
+20 -10
View File
@@ -3,7 +3,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, isNsfwManga } from "../../lib/util";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte"; import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
@@ -146,8 +146,11 @@
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal, FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const mangas = store.settings.showNsfw
? d.fetchSourceManga.mangas
: d.fetchSourceManga.mangas.filter((m) => !isNsfwManga(m));
kw_results = kw_results.map((r) => kw_results = kw_results.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r, r.source.id === src.id ? { ...r, mangas, loading: false } : r,
); );
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return; if (ctrl.signal.aborted || e?.name === "AbortError") return;
@@ -243,7 +246,8 @@
ctrl.signal, ctrl.signal,
).then((d) => { ).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
tag_localResults = d.mangas.nodes; const nsfwFilter = (m: Manga) => store.settings.showNsfw || !isNsfwManga(m);
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
tag_totalCount = d.mangas.totalCount; tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = (store.settings.renderLimit ?? 48); tag_localOffset = (store.settings.renderLimit ?? 48);
@@ -279,9 +283,10 @@
ps.add(1); ps.add(1);
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1); tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
tag_srcNextPage = new Map(tag_srcNextPage); tag_srcNextPage = new Map(tag_srcNextPage);
const matching = activeTags.length > 1 const matching = (activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas; : result.mangas
).filter((m) => store.settings.showNsfw || !isNsfwManga(m));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false; tag_loadingSourceSearch = false;
@@ -304,7 +309,8 @@
ctrl.signal, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
tag_localResults = [...tag_localResults, ...d.mangas.nodes]; const nsfwFilter = (m: Manga) => store.settings.showNsfw || !isNsfwManga(m);
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48); tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) { } catch (e: any) {
@@ -340,9 +346,10 @@
ps.add(page); ps.add(page);
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1); tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
tag_srcNextPage = new Map(tag_srcNextPage); tag_srcNextPage = new Map(tag_srcNextPage);
const matching = tag_activeTags.length > 1 const matching = (tag_activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas; : result.mangas
).filter((m) => store.settings.showNsfw || !isNsfwManga(m));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
} }
@@ -422,7 +429,10 @@
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal, FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas]; const incoming = store.settings.showNsfw
? d.fetchSourceManga.mangas
: d.fetchSourceManga.mangas.filter((m) => !isNsfwManga(m));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.hasNextPage; src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page; src_currentPage = page;
} catch (e: any) { } catch (e: any) {
@@ -1113,7 +1123,7 @@
padding: 4px 10px; padding: 4px 10px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: none; background: none;
border: none; border: 1px solid transparent;
color: var(--text-faint); color: var(--text-faint);
cursor: pointer; cursor: pointer;
transition: background var(--t-base), color var(--t-base); transition: background var(--t-base), color var(--t-base);
+80 -21
View File
@@ -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>
+175 -51
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte"; import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import { setReading } from "../../lib/discord";
import type { FitMode } from "../../store/state.svelte"; import type { FitMode } from "../../store/state.svelte";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -12,6 +13,10 @@
const AVG_MIN_PER_PAGE = 0.33; const AVG_MIN_PER_PAGE = 0.33;
const MAX_CACHED = 10; const MAX_CACHED = 10;
const READ_LINE_PCT = 0.20; const READ_LINE_PCT = 0.20;
// Zoom step per Ctrl+Wheel tick or keyboard shortcut (5% of viewer width)
const ZOOM_STEP = 0.05;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4.0;
// ─── Page cache ─────────────────────────────────────────────────────────────── // ─── Page cache ───────────────────────────────────────────────────────────────
@@ -93,6 +98,47 @@
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
// ─── Container width (for resolution-based zoom) ──────────────────────────────
// Tracked via ResizeObserver so 100% zoom always means "fills the viewer",
// regardless of screen resolution or window size.
let containerWidth = $state(0);
// ─── Zoom anchor (longstrip) ──────────────────────────────────────────────────
// Before zoom changes the layout we snapshot which image is at the top of the
// viewport and how far it is from the top edge. After the DOM re-renders at
// the new zoom we scroll back so that same image is at the same visual offset,
// preventing the "random page teleport" that occurs when scrollHeight changes.
let zoomAnchorEl: HTMLElement | null = null;
let zoomAnchorOffset: number = 0;
function captureZoomAnchor() {
if (!containerEl || style !== "longstrip") return;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of imgs) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) {
zoomAnchorEl = img;
zoomAnchorOffset = rect.top - containerTop;
return;
}
}
}
function restoreZoomAnchor() {
if (!zoomAnchorEl || !containerEl) return;
const el = zoomAnchorEl;
zoomAnchorEl = null;
// Use rAF to wait for the DOM to finish re-laying out after the zoom change.
requestAnimationFrame(() => {
const containerTop = containerEl.getBoundingClientRect().top;
const newRect = el.getBoundingClientRect();
containerEl.scrollTop += (newRect.top - containerTop) - zoomAnchorOffset;
});
}
// ─── UI state ───────────────────────────────────────────────────────────────── // ─── UI state ─────────────────────────────────────────────────────────────────
let loading = $state(true); let loading = $state(true);
@@ -121,14 +167,23 @@
// ─── 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));
const displayChapter = $derived( const displayChapter = $derived(
style === "longstrip" && autoNext && visibleChapterId style === "longstrip" && autoNext && visibleChapterId
@@ -136,6 +191,19 @@
: 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: [] };
@@ -199,8 +267,7 @@
error = null; error = null;
pageGroups = []; pageGroups = [];
pageReady = false; pageReady = false;
stripChapters = []; stripChapters = [];
visibleChapterId = null;
store.pageUrls = []; store.pageUrls = [];
store.pageNumber = 1; store.pageNumber = 1;
try { try {
@@ -217,9 +284,6 @@
} }
// ─── Strip initialisation ───────────────────────────────────────────────────── // ─── Strip initialisation ─────────────────────────────────────────────────────
// Runs when a chapter finishes loading in longstrip mode.
// Starts the strip with just the current chapter; appendNextChapter adds more
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
$effect(() => { $effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) { if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
@@ -240,8 +304,6 @@
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; }); $effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
// ─── Forward append only ────────────────────────────────────────────────────── // ─── Forward append only ──────────────────────────────────────────────────────
// Appends the next chapter to the bottom when the user scrolls past 80%.
// No eviction, no prepend, no sliding window — chapters accumulate forward.
function appendNextChapter() { function appendNextChapter() {
if (appending || !stripChapters.length) return; if (appending || !stripChapters.length) return;
@@ -399,17 +461,8 @@
}); });
// ─── Progress / history tracking ───────────────────────────────────────────── // ─── Progress / history tracking ─────────────────────────────────────────────
// Only records history after the user has genuinely navigated (pageNumber > 1,
// or scrolled past page 1 in longstrip). This prevents the chapter-open event
// from writing "page 1" as the last-read position, which caused the history to
// always show the chapter you started on rather than where you left off.
$effect(() => { $effect(() => {
// Use displayChapter, not store.activeChapter — in longstrip with autoNext,
// store.activeChapter stays as the chapter you *opened* (e.g. ch61) while
// displayChapter tracks visibleChapterId (the chapter actually on screen).
// Using store.activeChapter here caused every history write to stamp ch61
// even when the user had scrolled all the way to ch72.
const ch = displayChapter ?? store.activeChapter; const ch = displayChapter ?? store.activeChapter;
if (ch && lastPage && store.activeManga) { if (ch && lastPage && store.activeManga) {
const chapterId = ch.id; const chapterId = ch.id;
@@ -420,11 +473,9 @@
const pageNum = store.pageNumber; const pageNum = store.pageNumber;
const atLast = store.pageNumber === lastPage; const atLast = store.pageNumber === lastPage;
// Mark that the user has moved past the initial load.
if (pageNum > 1) hasNavigated = true; if (pageNum > 1) hasNavigated = true;
untrack(() => { untrack(() => {
// Skip the very first page-1 write that fires on chapter load.
if (!hasNavigated) return; if (!hasNavigated) return;
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() }); addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId); if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
@@ -457,7 +508,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 +559,24 @@
const goNext = $derived(rtl ? goBack : goForward); const goNext = $derived(rtl ? goBack : goForward);
const goPrev = $derived(rtl ? goForward : goBack); const goPrev = $derived(rtl ? goForward : goBack);
// ─── Zoom helpers ─────────────────────────────────────────────────────────────
function clampZoom(z: number): number {
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
}
function adjustZoom(delta: number) {
captureZoomAnchor();
updateSettings({ readerZoom: clampZoom(zoom + delta) });
restoreZoomAnchor();
}
function resetZoom() {
captureZoomAnchor();
updateSettings({ readerZoom: 1.0 });
restoreZoomAnchor();
}
// ─── Settings toggles ───────────────────────────────────────────────────────── // ─── Settings toggles ─────────────────────────────────────────────────────────
function cycleStyle() { function cycleStyle() {
@@ -532,13 +601,13 @@
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
e.preventDefault(); e.preventDefault();
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) }); // Each wheel tick adjusts by ZOOM_STEP (5%). Larger deltaY = bigger scroll = same step.
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS; const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const mW = store.settings.maxPageWidth ?? 900;
const r = store.settings.readingDirection === "rtl"; const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -546,9 +615,9 @@
if (dlOpen) { dlOpen = false; return; } if (dlOpen) { dlOpen = false; return; }
closeReader(); return; closeReader(); return;
} }
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; } if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; } if (e.ctrlKey && e.key === "-") { e.preventDefault(); adjustZoom(-ZOOM_STEP * 2); return; }
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; } if (e.ctrlKey && e.key === "0") { e.preventDefault(); resetZoom(); return; }
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
@@ -592,12 +661,20 @@
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
containerEl?.focus({ preventScroll: true }); containerEl?.focus({ preventScroll: true });
// Track the viewer's actual paint width so zoom is always relative to it.
const ro = new ResizeObserver(entries => {
containerWidth = entries[0].contentRect.width;
});
ro.observe(containerEl);
return () => { return () => {
abortCtrl?.abort(); abortCtrl?.abort();
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
cleanupScroll(); cleanupScroll();
ro.disconnect();
}; };
}); });
</script> </script>
@@ -626,16 +703,37 @@
{:else}<ArrowsOut size={14} weight="light" />{/if} {:else}<ArrowsOut size={14} weight="light" />{/if}
<span class="mode-label">{fitLabel}</span> <span class="mode-label">{fitLabel}</span>
</button> </button>
<!-- ── Zoom controls ────────────────────────────────────────────────────── -->
<div class="zoom-wrap"> <div class="zoom-wrap">
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button> <div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
</button>
<button class="zoom-pct-btn" onclick={() => zoomOpen = !zoomOpen} title="Click to adjust zoom">
{zoomPct}%
</button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
</button>
</div>
{#if zoomOpen} {#if zoomOpen}
<div class="zoom-popover"> <div class="zoom-popover">
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW} <div class="zoom-slider-row">
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} /> <input type="range" class="zoom-slider" min={10} max={400} step={5} value={zoomPct}
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button> oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
</div>
<div class="zoom-presets">
{#each [50, 75, 100, 125, 150, 200] as pct}
<button class="zoom-preset" class:active={zoomPct === pct}
onclick={() => { captureZoomAnchor(); updateSettings({ readerZoom: pct / 100 }); restoreZoomAnchor(); }}>{pct}%</button>
{/each}
</div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div> </div>
{/if} {/if}
</div> </div>
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}> <button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span> <ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
</button> </button>
@@ -667,7 +765,7 @@
bind:this={containerEl} bind:this={containerEl}
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
style="--max-page-width:{maxW}px" style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation" role="presentation"
tabindex="-1" tabindex="-1"
onclick={handleTap} onclick={handleTap}
@@ -711,11 +809,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>
@@ -771,25 +869,51 @@
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); } .mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); } .mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.mode-label { text-transform: capitalize; } .mode-label { text-transform: capitalize; }
/* ── Zoom controls ───────────────────────────────────────────────────────── */
.zoom-wrap { position: relative; flex-shrink: 0; } .zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); } .zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); } .zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; } .zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; } .zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } .zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; }
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); } .zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
.zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); }
.zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
/* ── Viewer ──────────────────────────────────────────────────────────────── */
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; } .viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; } /*
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; } * Fit modes — all constrain within --effective-width (the zoom-adjusted
* container width). effectiveWidth is set as a CSS variable on .viewer
* so every fit class automatically respects the current zoom level.
*
* fit-width : fills up to effectiveWidth, never wider
* fit-height : constrained to viewport height; never taller, never wider than effectiveWidth
* fit-screen : fits within both axes (contain); never wider than effectiveWidth
* fit-original : natural image size, no constraint
*/
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
.fit-original { max-width: none; width: auto; height: auto; } .fit-original { max-width: none; width: auto; height: auto; }
.strip-gap { margin-bottom: 8px; } .strip-gap { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; } .double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; } .page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; } .gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; } .gap-right { margin-left: 2px; }
+180 -35
View File
@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock } from "phosphor-svelte"; import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock, Eye, EyeSlash, Star } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries"; import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme } from "../../store/state.svelte"; import type { Category } from "../../lib/types";
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
import { cache } from "../../lib/cache"; import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
import type { Settings, FitMode, Theme } from "../../store/state.svelte"; import type { Settings, FitMode, Theme } from "../../store/state.svelte";
@@ -168,23 +170,104 @@
} }
let newFolderName = $state(""); // catsLoading / catsError are local UI state only.
let editingId: string | null = $state(null); // The category list itself lives in store.categories (shared with Library).
let editingName = $state(""); let catsLoading: boolean = $state(false);
let catsError: string|null = $state(null);
let newFolderName = $state("");
let editingId: number | null = $state(null);
let editingName = $state("");
function createFolder() { async function loadCategories() {
catsLoading = true; catsError = null;
try {
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
// Merge server data onto existing store.categories to preserve mangas.nodes
// that Library loaded — Settings' GET_CATEGORIES query may not include them.
// Also preserve any id=0 (Default) entry that Library manages.
const zeroCat = store.categories.filter(c => c.id === 0);
const fresh = res.categories.nodes.filter(c => c.id !== 0);
const merged = fresh.map(f => {
const existing = store.categories.find(c => c.id === f.id);
return existing ? { ...existing, ...f } : f;
});
setCategories([...zeroCat, ...merged]);
} catch (e: any) {
catsError = e?.message ?? "Failed to load folders";
} finally { catsLoading = false; }
}
async function createFolder() {
const name = newFolderName.trim(); const name = newFolderName.trim();
if (!name) return; if (!name) return;
addFolder(name); newFolderName = ""; try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
setCategories([...store.categories, res.createCategory.category]);
newFolderName = "";
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
} }
function startEdit(id: string, name: string) { editingId = id; editingName = name; } function startEdit(id: number, name: string) { editingId = id; editingName = name; }
function commitEdit() { async function commitEdit() {
if (editingId && editingName.trim()) renameFolder(editingId, editingName.trim()); if (editingId !== null && editingName.trim()) {
try {
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
setCategories(store.categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c));
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
}
editingId = null; editingName = ""; editingId = null; editingName = "";
} }
async function deleteFolder(id: number) {
try {
await gql(DELETE_CATEGORY, { id });
setCategories(store.categories.filter(c => c.id !== id));
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
}
async function moveCategory(id: number, direction: -1 | 1) {
// Work only on the non-default (id !== 0) categories, sorted by order,
// which is what the Settings list renders and what the server expects.
const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => a.order - b.order);
const idx = sortable.findIndex(c => c.id === id);
if (idx < 0) return;
const newPos = idx + 1 + direction; // 1-based server position
if (newPos < 1 || newPos > sortable.length) return;
// Optimistic reorder — splice within sortable slice, keep mangas.nodes intact
const reordered = [...sortable];
const [moved] = reordered.splice(idx, 1);
reordered.splice(idx + direction, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
try {
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
// Server returns bare order data — merge to preserve mangas.nodes, keep id=0 entry
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([
...zeroCat,
...updated
.sort((a, b) => a.order - b.order)
.map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
} catch (e: any) {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
}
}
// Load categories when the folders tab is first opened and the shared store
// is empty (e.g. user opened Settings before Library was mounted).
$effect(() => { if (tab === "folders" && !store.categories.length && !catsLoading) loadCategories(); });
let selectOpen: string | null = $state(null); let selectOpen: string | null = $state(null);
@@ -634,28 +717,30 @@
<div class="section"> <div class="section">
<p class="section-title">Interface Scale</p> <p class="section-title">Interface Scale</p>
<div class="scale-row"> <div class="scale-row">
<input type="range" min={50} max={200} step={5} value={store.settings.uiScale} <input type="range" min={50} max={200} step={5}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" /> value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input <input
type="number" min={50} max={200} step={1} type="number" min={50} max={200} step={1}
class="scale-val-input" class="scale-val-input"
value={store.settings.uiScale} value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => { oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10); const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n }); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 });
}} }}
onblur={(e) => { onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10); const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; } if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; }
else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; }
}} }}
/> />
<span class="scale-pct">%</span> <span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button> <button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.5 })} disabled={(store.settings.uiZoom ?? 1.5) === 1.5} title="Reset"></button>
</div> </div>
<p class="scale-hint"> <p class="scale-hint">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v} {#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button> <button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.5) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each} {/each}
</p> </p>
</div> </div>
@@ -690,6 +775,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section">
<p class="section-title">Integrations</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Discord Rich Presence</span><span class="toggle-desc">Show what you're reading in your Discord status</span></div>
<button role="switch" aria-checked={store.settings.discordRpc} aria-label="Discord Rich Presence" class="toggle" class:on={store.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !store.settings.discordRpc })}><span class="toggle-thumb"></span></button>
</label>
</div>
</div> </div>
@@ -848,13 +940,40 @@
</div> </div>
</div> </div>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div> <div class="toggle-info">
<div class="step-controls"> <span class="toggle-label">Default zoom</span>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}></button> <span class="toggle-desc">Starting zoom when opening a chapter. 100% = fills the reader.</span>
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span> </div>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button> <div class="scale-row">
<input type="range" min={10} max={400} step={5}
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input
type="number" min={10} max={400} step={5}
class="scale-val-input"
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; }
else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%"></button>
</div> </div>
</div> </div>
<p class="scale-hint">
{#each [50, 75, 100, 125, 150, 200] as v}
<button class="scale-preset"
class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v}
onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
{/each}
</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div> <div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
@@ -1132,7 +1251,10 @@
<div class="panel"> <div class="panel">
<div class="section"> <div class="section">
<p class="section-title">Manage Folders</p> <p class="section-title">Manage Folders</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p> <p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Folders are stored as Suwayomi categories. Changes sync across all clients.</p>
{#if catsError}
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);color:var(--color-error);display:block">{catsError}</p>
{/if}
<div class="folder-create-row"> <div class="folder-create-row">
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName} <input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" /> onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
@@ -1140,26 +1262,47 @@
<Plus size={13} weight="bold" /> Create <Plus size={13} weight="bold" /> Create
</button> </button>
</div> </div>
{#if store.settings.folders.length === 0} {#if catsLoading}
<p class="storage-loading">Loading folders…</p>
{:else if store.categories.filter(c => c.id !== 0).length === 0}
<p class="storage-loading">No folders yet. Create one above.</p> <p class="storage-loading">No folders yet. Create one above.</p>
{:else} {:else}
{@const displayCats = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1;
return a.order - b.order;
})}
<div class="folder-list"> <div class="folder-list">
{#each store.settings.folders as folder} {#each displayCats as cat, i}
<div class="folder-row"> <div class="folder-row">
{#if editingId === folder.id} {#if editingId === cat.id}
<input class="text-input" bind:value={editingName} <input class="text-input" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }} onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} style="flex:1;width:auto" use:focusInput /> onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
<button class="kb-reset" onclick={commitEdit} title="Save"></button> <button class="kb-reset" onclick={commitEdit} title="Save"></button>
{:else} {:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" /> <FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="folder-row-name">{folder.name}</span> <span class="folder-row-name">{cat.name}</span>
<span class="folder-row-count">{folder.mangaIds.length} manga</span> <span class="folder-row-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}> <button
{folder.showTab ? "Tab on" : "Tab off"} class="kb-reset"
</button> class:folder-default-active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
<button class="kb-reset" onclick={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button> onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button> title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder — opens first when you visit Library"}
><Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} /></button>
<button
class="kb-reset"
class:folder-hidden={(store.settings.hiddenCategoryIds ?? []).includes(cat.id)}
onclick={() => toggleHiddenCategory(cat.id)}
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}
>{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}</button>
<button class="kb-reset" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button>
<button class="kb-reset" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button>
<button class="kb-reset" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="kb-reset folder-delete" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -1642,7 +1785,7 @@
<p class="section-title">Links</p> <p class="section-title">Links</p>
<div class="about-block"> <div class="about-block">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a> <a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/cfncTbJ2" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a> <a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a>
</div> </div>
</div> </div>
@@ -1819,6 +1962,8 @@
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } .folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); } .folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; } .folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
.folder-hidden { opacity: 0.35; }
.folder-default-active { color: var(--accent-fg) !important; }
/* About */ /* About */
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); } .about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
+64 -14
View File
@@ -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>
+51 -28
View File
@@ -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
View File
@@ -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",
+81
View File
@@ -0,0 +1,81 @@
import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc";
import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity";
import type { Manga, Chapter } from "./types";
const APP_ID = "1487894643613106298";
const FALLBACK_IMAGE = "moku_logo";
function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === "string" && url.startsWith("https://");
}
function resolveCoverImage(manga: Manga): string {
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE;
}
function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`;
}
function formatChapter(chapter: Chapter): string {
const n = chapter.chapterNumber;
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
}
const BUTTONS = [
new Button("GitHub", "https://github.com/Youwes09/Moku"),
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
];
export async function initRpc(): Promise<void> {
await start(APP_ID)
.then(() => console.log("[discord] RPC started"))
.catch((e) => console.error("[discord] initRpc failed:", e));
}
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
const assets = new Assets()
.setLargeImage(resolveCoverImage(manga))
.setLargeText(trunc(manga.title))
.setSmallImage(FALLBACK_IMAGE)
.setSmallText("Moku");
const activity = new Activity()
.setDetails(trunc(manga.title))
.setState(`${formatChapter(chapter)} · Reading`)
.setAssets(assets)
.setTimestamps(new Timestamps(Date.now()));
activity.setButton(BUTTONS);
await setActivity(activity)
.then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter)))
.catch((e) => console.error("[discord] setActivity failed:", e));
}
export async function setIdle(): Promise<void> {
const assets = new Assets()
.setLargeImage(FALLBACK_IMAGE)
.setLargeText("Moku");
const activity = new Activity()
.setDetails("Browsing")
.setAssets(assets)
.setTimestamps(new Timestamps(Date.now()));
activity.setButton(BUTTONS);
await setActivity(activity)
.then(() => console.log("[discord] idle"))
.catch((e) => console.error("[discord] setActivity failed (idle):", e));
}
export async function clearReading(): Promise<void> {
await clearActivity()
.then(() => console.log("[discord] activity cleared"))
.catch((e) => console.error("[discord] clearActivity failed:", e));
}
export async function destroyRpc(): Promise<void> {
await stop()
.then(() => console.log("[discord] RPC stopped"))
.catch((e) => console.error("[discord] destroyRpc failed:", e));
}
+89
View File
@@ -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 = `
+13
View File
@@ -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;
+28
View File
@@ -5,6 +5,34 @@ export function cn(...inputs: ClassValue[]) {
return clsx(inputs); return clsx(inputs);
} }
// ── NSFW genre filtering ──────────────────────────────────────────────────────
/**
* Genre tags that indicate adult/mature content.
* Checked case-insensitively against each manga's genre array.
* Extend this set if additional tags need to be covered.
*/
const NSFW_GENRE_TAGS = new Set([
"adult",
"mature",
"hentai",
"ecchi",
"erotica",
"pornographic",
"18+",
"smut",
"lemon",
"explicit",
]);
/**
* Returns true if the manga carries at least one genre tag that is considered
* adult/mature. Used to enforce the `showNsfw` setting across all views.
*/
export function isNsfwManga(manga: { genre?: string[] | null }): boolean {
return (manga.genre ?? []).some((g) => NSFW_GENRE_TAGS.has(g.toLowerCase().trim()));
}
// ── Source deduplication ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
export function dedupeSources(sources: Source[], preferredLang: string): Source[] { export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
+117 -115
View File
@@ -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;
@@ -142,19 +160,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 +180,16 @@ export interface Settings {
libraryCropCovers: boolean; libraryCropCovers: boolean;
libraryPageSize: number; libraryPageSize: number;
showNsfw: boolean; showNsfw: boolean;
discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; chapterPageSize: number;
uiScale: number; /**
* UI zoom level unitless float multiplier applied on top of the
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
* Replaces the old `uiScale` percentage integer.
*/
uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
serverUrl: string; serverUrl: string;
@@ -178,7 +200,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 +225,25 @@ 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;
/** 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 +253,11 @@ export const DEFAULT_SETTINGS: Settings = {
libraryCropCovers: true, libraryCropCovers: true,
libraryPageSize: 48, libraryPageSize: 48,
showNsfw: false, showNsfw: false,
discordRpc: false,
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
chapterPageSize: 25, chapterPageSize: 25,
uiScale: 100, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
@@ -242,7 +268,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 +293,22 @@ export const DEFAULT_SETTINGS: Settings = {
appLockEnabled: false, appLockEnabled: false,
appLockPin: "", appLockPin: "",
customThemes: [], customThemes: [],
hiddenCategoryIds: [],
defaultLibraryCategoryId: null,
libraryTabSort: {},
libraryTabStatus: {},
}; };
// ── Persistence ─────────────────────────────────────────────────────────────── // ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 2; const STORE_VERSION = 3;
// Fields reset to their DEFAULT_SETTINGS value on each version bump. // Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases. // Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom",
"uiZoom",
]; ];
function loadPersisted(): any { function loadPersisted(): any {
@@ -318,20 +349,16 @@ 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 ?? {},
}; };
} }
@@ -350,7 +377,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
@@ -383,6 +410,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>"
@@ -504,31 +537,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 +571,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 +587,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 +602,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();
@@ -654,16 +655,13 @@ export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: n
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
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 +676,17 @@ 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 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);
}