Compare commits

...

8 Commits

Author SHA1 Message Date
Youwes09 82f8a9a36b Fix: Forgot Auto-Bookmark Toggle & NSFW On GenreDrill 2026-03-31 22:55:26 -05:00
Youwes09 4decce9a7f Fix: Reworked Bookmark System & Added Double Page (WIP) 2026-03-31 19:46:11 -05:00
Youwes09 a69d5eacc5 Fix: Improved Loading (WIP) 2026-03-31 11:28:00 -05:00
Youwes09 4959722759 Feat: Change Download Directory (WIP) 2026-03-30 23:14:40 -05:00
Youwes09 35ba0171c7 Fix: Zoom Issue & Sidebar Overflow 2026-03-30 00:26:04 -05:00
Youwes09 d26fa50e76 Chore: Bump to 0.6.1 2026-03-30 00:04:54 -05:00
Youwes09 fd9d216325 Fix: Emergency Push + Bookmark Feature (WIP) 2026-03-30 00:02:21 -05:00
Youwes09 581eb2adb0 Fix: Home-Screen Argument for RPC & Total Time 2026-03-29 17:35:25 -05:00
25 changed files with 1783 additions and 1128 deletions
+19 -10
View File
@@ -1,21 +1,19 @@
Major Revisions: Major Revisions:
- Moku + Crossplatform Support (MacOS Remaining)
- Contemplate Anime Support, Add Novel Support (Consumet API)
- Enable Cloudflare Bypass (Suwayomi Config)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Adjustment in Settings for Theme Editor:
- Allow User to Edit/Create Themes
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
Minor Revisions: Minor Revisions:
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive) - Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
- Integrate Download Directory Changes (Settings) - Integrate Download Directory Changes (Settings)
- Investigate feasibility of Multi-Page Screenshot (Reader) - Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks) - Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
Priority Bugs: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - Cache ALL Cover Pictures & Details for Manga in Library
- MacOS Full-Screen & UI Compatability (TitleBar) - Investigate Zoom (Reader), Appears to have Cutoff, etc.
General/Misc Bugs: General/Misc Bugs:
- Fix Highlightable Elements - Fix Highlightable Elements
@@ -25,9 +23,20 @@ General/Misc Bugs:
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?) - Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
In-Progress:` In-Progress:`
- Fix Reader Chapter Shifts (Glitched Sentinel) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Still Shifts Down after reading ~8+ Chapters? - Fix NSFW Parsing (Appears to not Work???)
- Identify When Chapters are Unloaded, How to Preserve Structure
- Check & Fix Zoom System
- Incredibly zoomed in on Windows (Appears to work fine on 1440p)
- Zoom Values are Incorrect
- Global Zoom should only scale Reader UI, not Manga
- Fix Resume-from-Read
- Start on Chapter 46 -> Go all the way to Chapter 47 (Page 28)
- Results in Opening Chapter 46 to take to last page of Chapter (Cache not Cleared).
- Add Event that if different chapter is opened, cache is cleared on all previous chapters.
- Add into Settings
Important Commands: Important Commands:
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 3f18e4cc9153e28fd9020f7de22aac6dad1891034833b683c4bc0f5d0e04fc2b sha256: fb01fc1a98499aeb5cf3e464c430a94c78ab1e68f15220ea8f95091f6ca593f2
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+1 -1
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.6.0"; version = "0.6.1";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
+44 -5
View File
@@ -2225,14 +2225,14 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.11.crate", "url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
"sha256": "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb", "sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
"dest": "cargo/vendor/iri-string-0.7.11" "dest": "cargo/vendor/iri-string-0.7.12"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb\", \"files\": {}}", "contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.11", "dest": "cargo/vendor/iri-string-0.7.12",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
@@ -4198,6 +4198,19 @@
"dest": "cargo/vendor/ring-0.17.14", "dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rpcdiscord/rpcdiscord-0.2.6.crate",
"sha256": "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d",
"dest": "cargo/vendor/rpcdiscord-0.2.6"
},
{
"type": "inline",
"contents": "{\"package\": \"71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d\", \"files\": {}}",
"dest": "cargo/vendor/rpcdiscord-0.2.6",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -5238,6 +5251,19 @@
"dest": "cargo/vendor/tauri-plugin-2.5.4", "dest": "cargo/vendor/tauri-plugin-2.5.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-drpc/tauri-plugin-drpc-0.1.6.crate",
"sha256": "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6"
},
{
"type": "inline",
"contents": "{\"package\": \"7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-drpc-0.1.6",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
@@ -6018,6 +6044,19 @@
"dest": "cargo/vendor/utf8_iter-1.0.4", "dest": "cargo/vendor/utf8_iter-1.0.4",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/uuid/uuid-0.8.2.crate",
"sha256": "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7",
"dest": "cargo/vendor/uuid-0.8.2"
},
{
"type": "inline",
"contents": "{\"package\": \"bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7\", \"files\": {}}",
"dest": "cargo/vendor/uuid-0.8.2",
"dest-filename": ".cargo-checksum.json"
},
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
+1 -1
View File
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
[lib] [lib]
+214 -15
View File
@@ -61,10 +61,14 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path);
} }
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
// macOS: ~/Library/Application Support/Tachidesk/downloads
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
let base = std::env::var("XDG_DATA_HOME") let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/"))); .unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads") base.join("Tachidesk").join("downloads")
} }
#[tauri::command] #[tauri::command]
@@ -104,6 +108,82 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the resolved default downloads path for the current platform.
/// This mirrors resolve_downloads_path("") so the frontend can display it.
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
/// Returns true if the given path exists and is a directory.
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
/// Creates a directory and all missing parent directories.
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
/// Moves all content from `src` into `dst`, then removes `src`.
/// Emits `migrate_progress` events: `{ done, total, current }`.
/// Only deletes the source tree after every file is confirmed copied.
#[tauri::command]
async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(()); // nothing to migrate
}
// Count files first so the frontend can show accurate progress
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": 0u64, "total": total, "current": ""
}));
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done,
"total": total,
"current": rel.to_string_lossy()
}));
}
}
// Only remove source after all files are confirmed copied
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
/// Returns the OS/monitor DPI scale factor for the window's current monitor. /// 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, /// 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. /// 1.251.5 on Windows displays with OS-level scaling applied.
@@ -248,7 +328,28 @@ struct ServerInvocation {
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
} }
/// Locate the `java` / `java.exe` binary inside a bundled JRE directory.
///
/// Expected layout (Windows and Linux):
/// <bundle_dir>/jre/bin/java[.exe]
///
/// On Windows `strip_unc` is applied so Java doesn't choke on `\\?\` prefixes.
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] checking path: {:?}", java));
do_log(log, &format!("[find_java] exists: {}", java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) { 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);
} }
@@ -261,7 +362,10 @@ 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 // ── 1. User-specified binary path ─────────────────────────────────────────
// Primary: honour the path as-is (doc-2 behaviour — trust the user).
// Fallback: if the path doesn't exist after stripping UNC, log a warning
// and continue so the bundled detection still has a chance.
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim())); let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists())); do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
@@ -272,17 +376,58 @@ fn resolve_server_binary(
working_dir: path.parent().map(|p| p.to_path_buf()), working_dir: path.parent().map(|p| p.to_path_buf()),
}); });
} }
return Err(SpawnError::NotConfigured( // Fallback: path was set but file is missing — warn and keep trying.
format!("Configured binary not found: {}", path.display()), do_log(log, "[resolve] WARNING: user-supplied path not found, falling through to bundled detection");
));
} }
// 2. Bundled sidecar (Windows / Linux AppImage) // Resolve and UNC-strip resource_dir once; used by all non-macOS branches.
#[cfg(not(target_os = "macos"))]
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
stripped
};
// ── 2. Bundled JRE + JAR (Windows / Linux — specific layout) ─────────────
// Primary path from doc-2: binaries/suwayomi-bundle/bin/Suwayomi-Server.jar
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
let resource_dir = app.path().resource_dir().unwrap_or_default(); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"]; let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
for name in &candidates {
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
do_log(log, &format!("[resolve] jar = {:?}", jar));
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
match find_java_in_bundle(&bundle_dir, log) {
Some(java) => {
do_log(log, &format!("[resolve] java found: {:?}", java));
if jar.exists() {
do_log(log, "[resolve] both java and jar found — using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
do_log(log, "[resolve] java found but jar MISSING — falling through");
}
None => {
do_log(log, "[resolve] java NOT found in bundle — falling through");
}
}
}
// ── 2b. Bundled launcher scripts / native sidecars (Windows / Linux) ──────
// Fallback for older bundle layouts that ship a wrapper script instead of a
// bare JRE + JAR. Also scans for any *.jar in resource_dir as a last resort.
#[cfg(not(target_os = "macos"))]
{
// Named launcher scripts.
let script_candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &script_candidates {
let p = resource_dir.join(name); let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists())); do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
if p.exists() { if p.exists() {
@@ -290,24 +435,64 @@ fn resolve_server_binary(
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
args: vec![], args: vec![],
working_dir: Some(resource_dir), working_dir: Some(resource_dir.clone()),
}); });
} }
} }
// Generic JRE at resource_dir root + any *.jar alongside it.
do_log(log, "[resolve] no named sidecar found, trying generic JRE + any jar in resource_dir");
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| {
e.as_ref()
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
.unwrap_or(false)
})
.and_then(|e| e.ok())
.map(|e| e.path())
});
do_log(log, &format!("[resolve] generic jar candidate: {:?}", jar));
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] using generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
do_log(log, "[resolve] generic JRE found but no .jar — falling through");
}
} }
// 3. macOS app bundle — look in MacOS/ and Resources/ // ── 3. macOS app bundle — MacOS/ then Resources/ ──────────────────────────
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let resource_dir = app.path().resource_dir().unwrap_or_default(); let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir.parent() let macos_dir = resource_dir
.parent()
.map(|p| p.join("MacOS")) .map(|p| p.join("MacOS"))
.unwrap_or_default(); .unwrap_or_default();
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"]; do_log(log, &format!("[resolve] macOS macos_dir = {:?}", macos_dir));
// Tauri strips the target triple when installing externalBin sidecars into
// Contents/MacOS/, so the binary is "suwayomi-server" at runtime.
// Triple-suffixed names are kept as a belt-and-suspenders fallback for
// dev / flat layouts.
let candidates = [
"suwayomi-server",
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
// Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources.
for search_dir in &[&macos_dir, &resource_dir] { for search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates { for name in &candidates {
let p = search_dir.join(name); let p = search_dir.join(name);
@@ -324,8 +509,18 @@ fn resolve_server_binary(
} }
} }
// ── 4. PATH fallback ──────────────────────────────────────────────────────
// Use `where` on Windows, `which` everywhere else.
do_log(log, "[resolve] trying PATH fallback"); do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let found = std::process::Command::new("where")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which") let found = std::process::Command::new("which")
.arg(name) .arg(name)
.output() .output()
@@ -536,6 +731,10 @@ pub fn run() {
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server, spawn_server,
kill_server, kill_server,
get_platform_ui_scale, get_platform_ui_scale,
+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.6.0", "version": "0.6.1",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+37 -23
View File
@@ -76,22 +76,25 @@
let idle = $state(false); let idle = $state(false);
let devSplash = $state(false); let devSplash = $state(false);
// The OS/monitor DPI scale factor for the current display.
// Queried from Rust (window.scale_factor()) on mount and updated live
// whenever the window moves to a different monitor via the scaleChanged event.
// 1.0 = standard display, 2.0 = HiDPI/4K, 1.251.5 = Windows scaled display.
let platformScale = $state(1.0); let platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
// Applied to document.documentElement so the entire UI scales correctly.
function applyZoom() { function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.5; const uiZoom = store.settings.uiZoom ?? 1.0;
const effective = platformScale * uiZoom; if (uiZoom === _appliedZoom) return;
const pct = effective * 100; _appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`; document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
// visual-vh compensates for the zoom so 100vh-based calculations stay correct. if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`); _vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -136,9 +139,8 @@
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.uiZoom; platformScale; void store.settings.uiZoom;
applyZoom(); applyZoom();
}); });
@@ -226,8 +228,6 @@
document.addEventListener("contextmenu", e => e.preventDefault()); document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true; (window as any).__mokuShowSplash = () => devSplash = true;
// Fetch the real monitor scale factor from Rust (window.scale_factor()).
// This reflects actual DPI — 2.0 on HiDPI, 1.25 on Windows scaled displays, etc.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0); platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom(); applyZoom();
@@ -237,9 +237,6 @@
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) => { const unlistenScale = await win.onScaleChanged(async (event) => {
platformScale = event.payload.scaleFactor; platformScale = event.payload.scaleFactor;
applyZoom(); applyZoom();
@@ -288,14 +285,31 @@
} }
}); });
// When the reader closes, show idle presence.
$effect(() => { $effect(() => {
if (!store.activeChapter) { if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle(); if (store.settings.discordRpc) setIdle();
} }
}); });
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
});
function handleRetry() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
@@ -320,7 +334,7 @@
onRetry={handleRetry} onRetry={handleRetry}
onBypass={handleBypass} /> onBypass={handleBypass} />
{:else} {:else}
<div class="root"> <div id="app-shell" class="root">
{#if idle && !store.activeChapter} {#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} /> onDismiss={() => { idle = false; resetIdle(); }} />
@@ -344,4 +358,4 @@
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; }
</style> </style>
+6 -5
View File
@@ -50,19 +50,20 @@
</aside> </aside>
<style> <style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; } .root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; } .logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; } .logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); } .nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); } .nav::-webkit-scrollbar { display: none; }
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); } .tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); } .tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); } .bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); } .settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } .settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+90 -41
View File
@@ -17,8 +17,8 @@
}); });
const icons: Record<Toast["kind"], string> = { const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14", download: "M12 3v13M7 11l5 5 5-5M5 21h14",
}; };
@@ -27,10 +27,15 @@
{#if store.toasts.length} {#if store.toasts.length}
<div class="toaster" aria-live="polite"> <div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)} {#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert"> <div
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div>
<span class="icon"> <span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" <svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} /> <path d={icons[t.kind]} />
</svg> </svg>
</span> </span>
@@ -38,12 +43,6 @@
<p class="title">{t.title}</p> <p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if} {#if t.body}<p class="sub">{t.body}</p>{/if}
</div> </div>
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -51,41 +50,91 @@
<style> <style>
.toaster { .toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5); position: fixed;
z-index: 9999; display: flex; flex-direction: column; bottom: var(--sp-5);
gap: var(--sp-2); pointer-events: none; max-width: 320px; right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
} }
.toast { .toast {
display: flex; align-items: flex-start; gap: var(--sp-2); display: flex;
padding: var(--sp-2) var(--sp-3); align-items: center;
border-radius: var(--radius-lg); gap: var(--sp-2);
border: 1px solid var(--border-base); padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised); background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08); border: 1px solid var(--border-dim);
pointer-events: all; min-width: 220px; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both; pointer-events: all;
min-width: 200px;
overflow: hidden;
cursor: pointer;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 0.15s ease, transform 0.15s ease;
} }
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); } .toast:hover { opacity: 0.85; transform: translateX(-2px); }
to { opacity: 1; transform: translateX(0) scale(1); } .toast:active { transform: translateX(0) scale(0.98); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
} }
.toast-success { border-color: var(--accent-dim); }
.toast-success .icon { color: var(--accent-fg); } .accent-bar {
.toast-error { border-color: var(--color-error); } width: 3px;
.toast-error .icon { color: var(--color-error); } align-self: stretch;
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); } flex-shrink: 0;
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); } border-radius: 0 2px 2px 0;
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } margin-right: 2px;
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; } }
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); }
.toast-error .icon { color: var(--color-error); }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub { .sub {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); font-family: var(--font-ui);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.close {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
</style> </style>
+3 -3
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, isNsfwManga } from "../../lib/util"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte"; import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
@@ -62,7 +62,7 @@
function filterOut(mangas: Manga[]): Manga[] { function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => { return dedup(mangas.filter(m => {
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false; if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
if (!store.settings.showNsfw && isNsfwManga(m)) return false; if (shouldHideNsfw(m, store.settings)) return false;
return true; return true;
})); }));
} }
@@ -188,7 +188,7 @@
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const local = dedup( const local = dedup(
d.mangas.nodes.filter(m => store.settings.showNsfw || !isNsfwManga(m)) d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
); );
store.discoverCache.set(localKey, local); store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT)); genreResults.set(genre, local.slice(0, GRID_LIMIT));
+3 -3
View File
@@ -4,7 +4,7 @@
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, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } 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, shouldHideNsfw } from "../../lib/util";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte"; import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source, Category } 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";
@@ -47,9 +47,9 @@
let abortCtrl: AbortController | null = null; let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => { const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
const libIds = new Set(libMatches.map((m) => m.id)); const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]); return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
}); });
const visibleItems = $derived(filtered.slice(0, visibleCount)); const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length); const hasMoreVisible = $derived(visibleCount < filtered.length);
+13 -5
View File
@@ -173,7 +173,11 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
} }
openReader(chapter, all); if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
openReader(chapter, all);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; } } catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -188,8 +192,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0]; const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters); if (ch) {
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, chapters);
}
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; } } catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; } finally { resuming = false; }
} }
@@ -199,8 +205,10 @@
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0]; const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters); if (ch) {
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, chapters);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; } } catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
} }
+2 -4
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries"; import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, isNsfwManga } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte";
import type { Manga, Category, Chapter } from "../../lib/types"; import type { Manga, Category, Chapter } from "../../lib/types";
@@ -320,9 +320,7 @@
} }
// 2. NSFW filter — always applied before text search or sort // 2. NSFW filter — always applied before text search or sort
if (!store.settings.showNsfw) { items = items.filter(m => !shouldHideNsfw(m, store.settings));
items = items.filter(m => !isNsfwManga(m));
}
// 3. Text search // 3. Text search
if (q) items = items.filter(m => m.title.toLowerCase().includes(q)); if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
+103 -305
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, isNsfwManga } from "../../lib/util"; import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte"; import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
@@ -91,14 +91,11 @@
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort()); const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1); const hasMultipleLangs = $derived(availableLangs.length > 1);
// ── Keyword search ────────────────────────────────────────────────────────
let kw_query = $state(""); let kw_query = $state("");
let kw_submitted = $state(""); let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]); let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false); let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set()); let kw_selectedLangs: Set<string> = $state(new Set());
let kw_includeNsfw = $state(false);
let kw_inputEl: HTMLInputElement | null = $state(null); let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null; let kw_abortCtrl: AbortController | null = null;
@@ -124,7 +121,7 @@
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang)); filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!kw_includeNsfw) if (!store.settings.showNsfw)
filtered = filtered.filter((s) => !s.isNsfw); filtered = filtered.filter((s) => !s.isNsfw);
return filtered; return filtered;
} }
@@ -146,9 +143,7 @@
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 const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
? 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, loading: false } : r, r.source.id === src.id ? { ...r, mangas, loading: false } : r,
); );
@@ -172,8 +167,6 @@
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0)); const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading)); const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
// ── Tag search ────────────────────────────────────────────────────────────
let tag_activeTags: string[] = $state([]); let tag_activeTags: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND"); let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state(""); let tag_tagFilter = $state("");
@@ -246,7 +239,7 @@
ctrl.signal, ctrl.signal,
).then((d) => { ).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => store.settings.showNsfw || !isNsfwManga(m); const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = d.mangas.nodes.filter(nsfwFilter); 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;
@@ -286,7 +279,7 @@
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)); ).filter((m) => !shouldHideNsfw(m, store.settings));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false; tag_loadingSourceSearch = false;
@@ -309,8 +302,7 @@
ctrl.signal, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => store.settings.showNsfw || !isNsfwManga(m); const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
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) {
@@ -349,7 +341,7 @@
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)); ).filter((m) => !shouldHideNsfw(m, store.settings));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
} }
@@ -374,9 +366,7 @@
} }
} }
// ── Source browse ───────────────────────────────────────────────────────── let src_selectedLang = $state(preferredLang || "all");
let src_selectedLang = $state("all");
let src_activeSource: Source | null = $state(null); let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]); let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false); let src_loadingBrowse = $state(false);
@@ -385,40 +375,33 @@
let src_hasNextPage = $state(false); let src_hasNextPage = $state(false);
let src_currentPage = $state(1); let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null; let src_abortCtrl: AbortController | null = null;
let src_langPocketOpen = $state(true);
let src_expandedGroups: Set<string> = $state(new Set());
// Group sources by displayName — sources with same name but different langs get grouped $effect(() => {
interface SourceGroup { if (!allSources.length) return;
name: string; const langs = new Set(allSources.map((s) => s.lang));
iconUrl: string; if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
sources: Source[]; src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
isNsfw: boolean; }
} });
const src_visibleSources = $derived(src_selectedLang === "all" const src_visibleSources = $derived.by(() => {
? allSources const nsfw = (s: Source) => !store.settings.showNsfw && s.isNsfw;
: allSources.filter((s) => s.lang === src_selectedLang)); if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !nsfw(s));
const src_groupedSources = $derived.by(() => { }
const filtered = src_visibleSources; const map = new Map<string, Source>();
const map = new Map<string, SourceGroup>(); for (const s of allSources) {
for (const src of filtered) { if (nsfw(s)) continue;
const key = src.displayName; const key = s.name;
if (!map.has(key)) { const existing = map.get(key);
map.set(key, { name: src.displayName, iconUrl: src.iconUrl, sources: [], isNsfw: src.isNsfw }); if (!existing) { map.set(key, s); continue; }
if (s.lang === preferredLang || (!existing || (existing.lang !== preferredLang && s.lang < existing.lang))) {
map.set(key, s);
} }
map.get(key)!.sources.push(src);
} }
return Array.from(map.values()); return Array.from(map.values());
}); });
function srcToggleGroup(name: string) {
const next = new Set(src_expandedGroups);
if (next.has(name)) next.delete(name); else next.add(name);
src_expandedGroups = next;
}
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) { async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort(); src_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -429,9 +412,7 @@
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;
const incoming = store.settings.showNsfw const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
? d.fetchSourceManga.mangas
: d.fetchSourceManga.mangas.filter((m) => !isNsfwManga(m));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming]; src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.hasNextPage; src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page; src_currentPage = page;
@@ -580,10 +561,6 @@
{/each} {/each}
</div> </div>
<div class="advancedDivider"></div> <div class="advancedDivider"></div>
<label class="advancedCheck">
<input type="checkbox" bind:checked={kw_includeNsfw} class="checkbox" />
Include NSFW sources
</label>
<div class="advancedFooter"> <div class="advancedFooter">
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""} Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
</div> </div>
@@ -877,27 +854,18 @@
<div class="splitRoot"> <div class="splitRoot">
<div class="splitSidebar"> <div class="splitSidebar">
<button class="langPocketToggle" onclick={() => (src_langPocketOpen = !src_langPocketOpen)}> <div class="srcLangRow">
<span class="langPocketLabel">Languages</span> <span class="langPocketLabel">Language</span>
<svg width="9" height="9" viewBox="0 0 256 256" fill="currentColor" <select
style="transition: transform 0.2s ease; transform: rotate({src_langPocketOpen ? 180 : 0}deg)" class="langSelect"
aria-hidden="true"> bind:value={src_selectedLang}
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/> >
</svg> <option value="all">All</option>
</button> {#each availableLangs as lang (lang)}
{#if src_langPocketOpen} <option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
<div class="langPocket"> {/each}
{#each ["all", ...availableLangs] as lang (lang)} </select>
<button </div>
class="langChip"
class:langChipActive={src_selectedLang === lang}
onclick={() => (src_selectedLang = lang)}
>
{lang === "all" ? "All" : lang.toUpperCase()}
</button>
{/each}
</div>
{/if}
{#if loadingSources} {#if loadingSources}
<div class="splitLoading"> <div class="splitLoading">
@@ -907,52 +875,22 @@
</div> </div>
{:else} {:else}
<div class="splitList"> <div class="splitList">
{#each src_groupedSources as group (group.name)} {#each src_visibleSources as src (src.id)}
{#if group.sources.length === 1} <button
<button class="splitItem splitItemSource"
class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id}
class:splitItemActive={src_activeSource?.id === group.sources[0].id} onclick={() => srcSelectSource(src)}
onclick={() => srcSelectSource(group.sources[0])} >
> <img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon"
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> <span class="splitItemLabel">{src.name}</span>
<span class="splitItemLabel">{group.name}</span> {#if src_selectedLang === "all"}
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{group.sources[0].lang.toUpperCase()}</span> <span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
{#if group.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{:else}
<button
class="splitItem splitItemSource splitItemGroup"
class:splitItemGroupOpen={src_expandedGroups.has(group.name)}
onclick={() => srcToggleGroup(group.name)}
>
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{group.name}</span>
<span class="groupLangCount">{group.sources.length}</span>
<svg width="8" height="8" viewBox="0 0 256 256" fill="currentColor"
class="groupChevron"
style="transform: rotate({src_expandedGroups.has(group.name) ? 180 : 0}deg)"
aria-hidden="true">
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/>
</svg>
</button>
{#if src_expandedGroups.has(group.name)}
{#each group.sources as src (src.id)}
<button
class="splitItem splitItemSource splitItemLangOption"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
>
<span class="langOptionDot"></span>
<span class="splitItemLabel">{src.lang.toUpperCase()}</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
{/if} {/if}
{/if} {#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each} {/each}
{#if src_groupedSources.length === 0} {#if src_visibleSources.length === 0}
<p class="splitEmpty">No sources for this language</p> <p class="splitEmpty">No sources for this language</p>
{/if} {/if}
</div> </div>
@@ -1071,8 +1009,6 @@
</div> </div>
<style> <style>
/* ── Root ──────────────────────────────────────────────────────────────── */
.root { .root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1080,9 +1016,6 @@
overflow: hidden; overflow: hidden;
animation: fadeIn 0.14s ease both; animation: fadeIn 0.14s ease both;
} }
/* ── Header ────────────────────────────────────────────────────────────── */
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1091,7 +1024,6 @@
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
} }
.heading { .heading {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1100,9 +1032,6 @@
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
} }
/* ── Tabs ──────────────────────────────────────────────────────────────── */
.tabs { .tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
@@ -1111,7 +1040,6 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 2px; padding: 2px;
} }
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1130,16 +1058,12 @@
white-space: nowrap; white-space: nowrap;
} }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tabActive { .tabActive {
background: var(--accent-muted); background: var(--accent-muted);
color: var(--accent-fg); color: var(--accent-fg);
border: 1px solid var(--accent-dim); border: 1px solid var(--accent-dim);
} }
.tabActive:hover { color: var(--accent-fg); } .tabActive:hover { color: var(--accent-fg); }
/* ── Keyword bar ───────────────────────────────────────────────────────── */
.keywordBar { .keywordBar {
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
flex-shrink: 0; flex-shrink: 0;
@@ -1147,7 +1071,6 @@
flex-direction: column; flex-direction: column;
gap: var(--sp-2); gap: var(--sp-2);
} }
.searchBar { .searchBar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1159,9 +1082,7 @@
transition: border-color var(--t-base); transition: border-color var(--t-base);
} }
.searchBar:focus-within { border-color: var(--border-strong); } .searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; } .searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { .searchInput {
flex: 1; flex: 1;
background: none; background: none;
@@ -1172,7 +1093,6 @@
padding: 7px 0; padding: 7px 0;
} }
.searchInput::placeholder { color: var(--text-faint); } .searchInput::placeholder { color: var(--text-faint); }
.clearBtn { .clearBtn {
color: var(--text-faint); color: var(--text-faint);
font-size: 14px; font-size: 14px;
@@ -1184,7 +1104,6 @@
transition: color var(--t-base); transition: color var(--t-base);
} }
.clearBtn:hover { color: var(--text-muted); } .clearBtn:hover { color: var(--text-muted); }
.advancedBtn { .advancedBtn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1202,7 +1121,6 @@
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } .advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); } .advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.searchBtn { .searchBtn {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1221,9 +1139,6 @@
} }
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } .searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
.searchBtn:disabled { opacity: 0.4; cursor: default; } .searchBtn:disabled { opacity: 0.4; cursor: default; }
/* ── Advanced filter panel ─────────────────────────────────────────────── */
.advancedPanel { .advancedPanel {
background: var(--bg-raised); background: var(--bg-raised);
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
@@ -1234,13 +1149,11 @@
gap: var(--sp-2); gap: var(--sp-2);
animation: fadeIn 0.1s ease both; animation: fadeIn 0.1s ease both;
} }
.advancedHeader { .advancedHeader {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.advancedTitle { .advancedTitle {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1248,9 +1161,7 @@
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
} }
.advancedActions { display: flex; gap: var(--sp-1); } .advancedActions { display: flex; gap: var(--sp-1); }
.advancedLink { .advancedLink {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1264,13 +1175,11 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.advancedLink:hover { opacity: 1; } .advancedLink:hover { opacity: 1; }
.langGrid { .langGrid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--sp-1); gap: var(--sp-1);
} }
.langChip { .langChip {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1284,20 +1193,17 @@
transition: color var(--t-base), border-color var(--t-base), background var(--t-base); transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
} }
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); } .langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
.langChipActive { .langChipActive {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--accent-dim); border-color: var(--accent-dim);
color: var(--accent-fg); color: var(--accent-fg);
} }
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } .langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.advancedDivider { .advancedDivider {
height: 1px; height: 1px;
background: var(--border-dim); background: var(--border-dim);
margin: 2px 0; margin: 2px 0;
} }
.advancedCheck { .advancedCheck {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1307,16 +1213,13 @@
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
} }
.checkbox { accent-color: var(--accent-fg); cursor: pointer; } .checkbox { accent-color: var(--accent-fg); cursor: pointer; }
.advancedFooter { .advancedFooter {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
} }
.advancedLinkStandalone { .advancedLinkStandalone {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1333,9 +1236,6 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.advancedLinkStandalone:hover { opacity: 1; } .advancedLinkStandalone:hover { opacity: 1; }
/* ── Empty states ──────────────────────────────────────────────────────── */
.empty { .empty {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1344,33 +1244,26 @@
justify-content: center; justify-content: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
.emptyIcon { color: var(--text-faint); } .emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); } .emptyText { font-size: var(--text-base); color: var(--text-muted); }
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } .emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
/* ── Keyword results ───────────────────────────────────────────────────── */
.results { .results {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sourceSection { .sourceSection {
padding: var(--sp-1) var(--sp-4) var(--sp-3); padding: var(--sp-1) var(--sp-4) var(--sp-3);
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
} }
.sourceSection:last-child { border-bottom: none; } .sourceSection:last-child { border-bottom: none; }
.sourceHeader { .sourceHeader {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
.sourceIcon { .sourceIcon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1379,13 +1272,11 @@
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-raised); background: var(--bg-raised);
} }
.sourceName { .sourceName {
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
color: var(--text-secondary); color: var(--text-secondary);
} }
.sourceLang { .sourceLang {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1396,7 +1287,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 1px 5px; padding: 1px 5px;
} }
.resultCount { .resultCount {
margin-left: auto; margin-left: auto;
font-family: var(--font-ui); font-family: var(--font-ui);
@@ -1404,15 +1294,12 @@
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
} }
.sourceError { .sourceError {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-error); color: var(--color-error);
padding: var(--sp-1) 0; padding: var(--sp-1) 0;
margin: 0; margin: 0;
} }
/* Horizontal scroll row */
.sourceRow { .sourceRow {
display: flex; display: flex;
gap: var(--sp-3); gap: var(--sp-3);
@@ -1421,9 +1308,6 @@
scrollbar-width: none; scrollbar-width: none;
} }
.sourceRow::-webkit-scrollbar { display: none; } .sourceRow::-webkit-scrollbar { display: none; }
/* ── Manga card ────────────────────────────────────────────────────────── */
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1438,7 +1322,6 @@
} }
.card:hover .cover { filter: brightness(1.06); } .card:hover .cover { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); } .card:hover .cardTitle { color: var(--text-primary); }
.coverWrap { .coverWrap {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -1449,14 +1332,12 @@
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
transform: translateZ(0); transform: translateZ(0);
} }
.cover { .cover {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: filter var(--t-base); transition: filter var(--t-base);
} }
.inLibBadge { .inLibBadge {
position: absolute; position: absolute;
bottom: var(--sp-1); bottom: var(--sp-1);
@@ -1471,7 +1352,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--accent-muted); border: 1px solid var(--accent-muted);
} }
.cardTitle { .cardTitle {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
@@ -1482,9 +1362,6 @@
overflow: hidden; overflow: hidden;
transition: color var(--t-base); transition: color var(--t-base);
} }
/* ── Skeleton ──────────────────────────────────────────────────────────── */
.skCard { .skCard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1492,30 +1369,20 @@
flex-shrink: 0; flex-shrink: 0;
width: 110px; width: 110px;
} }
.tagGrid .card { width: 100%; } .tagGrid .card { width: 100%; }
.tagGrid .skCard { width: 100%; } .tagGrid .skCard { width: 100%; }
.skeleton { border-radius: var(--radius-sm); } .skeleton { border-radius: var(--radius-sm); }
.skCover { .skCover {
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
width: 100%; width: 100%;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.skTitle { height: 10px; width: 80%; } .skTitle { height: 10px; width: 80%; }
/* ── Split root (Tag + Source tabs) ────────────────────────────────────── */
.splitRoot { .splitRoot {
flex: 1; flex: 1;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
/* ── Split sidebar ─────────────────────────────────────────────────────── */
.splitSidebar { .splitSidebar {
width: 180px; width: 180px;
flex-shrink: 0; flex-shrink: 0;
@@ -1524,7 +1391,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.splitSearchWrap { .splitSearchWrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1533,9 +1399,7 @@
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-shrink: 0;
} }
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; } .splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput { .splitSearchInput {
flex: 1; flex: 1;
background: none; background: none;
@@ -1547,7 +1411,6 @@
min-width: 0; min-width: 0;
} }
.splitSearchInput::placeholder { color: var(--text-faint); } .splitSearchInput::placeholder { color: var(--text-faint); }
.splitSearchClear { .splitSearchClear {
color: var(--text-faint); color: var(--text-faint);
font-size: 13px; font-size: 13px;
@@ -1559,7 +1422,6 @@
transition: color var(--t-base); transition: color var(--t-base);
} }
.splitSearchClear:hover { color: var(--text-muted); } .splitSearchClear:hover { color: var(--text-muted); }
.splitList { .splitList {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1567,7 +1429,6 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent; scrollbar-color: var(--border-dim) transparent;
} }
.splitItem { .splitItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1582,13 +1443,11 @@
transition: background var(--t-fast), border-color var(--t-fast); transition: background var(--t-fast), border-color var(--t-fast);
} }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); } .splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { .splitItemActive {
background: var(--accent-muted); background: var(--accent-muted);
border-color: var(--accent-dim); border-color: var(--accent-dim);
} }
.splitItemActive:hover { background: var(--accent-muted); } .splitItemActive:hover { background: var(--accent-muted); }
.splitItemLabel { .splitItemLabel {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-muted); color: var(--text-muted);
@@ -1598,9 +1457,7 @@
white-space: nowrap; white-space: nowrap;
} }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); } .splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitItemSource { gap: var(--sp-2); } .splitItemSource { gap: var(--sp-2); }
.splitEmpty { .splitEmpty {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1608,7 +1465,6 @@
padding: var(--sp-3); padding: var(--sp-3);
margin: 0; margin: 0;
} }
.splitLoading { .splitLoading {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1616,16 +1472,12 @@
justify-content: center; justify-content: center;
padding: var(--sp-6); padding: var(--sp-6);
} }
/* ── Split content ─────────────────────────────────────────────────────── */
.splitContent { .splitContent {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.splitContentHeader { .splitContentHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1635,7 +1487,6 @@
flex-shrink: 0; flex-shrink: 0;
gap: var(--sp-2); gap: var(--sp-2);
} }
.splitSourceTitle { .splitSourceTitle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1643,7 +1494,6 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.splitContentTitle { .splitContentTitle {
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
@@ -1653,7 +1503,6 @@
white-space: nowrap; white-space: nowrap;
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.splitResultCount { .splitResultCount {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1661,7 +1510,6 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
flex-shrink: 0; flex-shrink: 0;
} }
.splitSourceIcon { .splitSourceIcon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1670,9 +1518,6 @@
flex-shrink: 0; flex-shrink: 0;
background: var(--bg-raised); background: var(--bg-raised);
} }
/* ── Tag active bar ────────────────────────────────────────────────────── */
.tagActiveBar { .tagActiveBar {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1682,7 +1527,6 @@
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.tagPillRow { .tagPillRow {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1690,7 +1534,6 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.tagPill { .tagPill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1704,7 +1547,6 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
color: var(--accent-fg); color: var(--accent-fg);
} }
.tagPillRemove { .tagPillRemove {
color: var(--accent-fg); color: var(--accent-fg);
opacity: 0.6; opacity: 0.6;
@@ -1717,21 +1559,18 @@
transition: opacity var(--t-base); transition: opacity var(--t-base);
} }
.tagPillRemove:hover { opacity: 1; } .tagPillRemove:hover { opacity: 1; }
.tagBarRight { .tagBarRight {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
.tagModeToggle { .tagModeToggle {
display: flex; display: flex;
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
} }
.tagModeBtn { .tagModeBtn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1751,7 +1590,6 @@
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } .tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } .tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.tagClearAll { .tagClearAll {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1771,15 +1609,11 @@
border-color: color-mix(in srgb, var(--color-error) 40%, transparent); border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
} }
.tagCheckMark { .tagCheckMark {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--accent-fg); color: var(--accent-fg);
margin-left: auto; margin-left: auto;
} }
/* ── Grid results ──────────────────────────────────────────────────────── */
.tagGrid { .tagGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
@@ -1789,9 +1623,6 @@
flex: 1; flex: 1;
align-content: start; align-content: start;
} }
/* ── Show more / load more ─────────────────────────────────────────────── */
.showMoreCell { .showMoreCell {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@@ -1799,7 +1630,6 @@
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
.showMoreBtn { .showMoreBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1821,7 +1651,6 @@
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.showMoreBtn:disabled { opacity: 0.4; cursor: default; } .showMoreBtn:disabled { opacity: 0.4; cursor: default; }
.loadMoreRow { .loadMoreRow {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1829,9 +1658,6 @@
flex-shrink: 0; flex-shrink: 0;
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
} }
/* ── Source tab: lang filter + browse bar ──────────────────────────────── */
.sourceBrowseBar { .sourceBrowseBar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1840,9 +1666,54 @@
border-bottom: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-shrink: 0;
} }
.srcLangRow {
/* ── NSFW badge ────────────────────────────────────────────────────────── */ display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
gap: var(--sp-2);
}
.langPocketLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.langSelect {
appearance: none;
-webkit-appearance: none;
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 4px 24px 4px 8px;
cursor: pointer;
max-width: 110px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 7px center;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.langSelect:hover {
border-color: var(--border-strong);
background-color: var(--bg-raised);
color: var(--text-primary);
}
.langSelect:focus {
outline: none;
border-color: var(--accent-dim);
color: var(--text-primary);
}
.langSelect option {
background: var(--bg-surface);
color: var(--text-secondary);
}
.nsfwBadge { .nsfwBadge {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -1855,81 +1726,8 @@
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Language pocket ───────────────────────────────────────────────────── */
.langPocketToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
border-top: none;
border-left: none;
border-right: none;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-fast);
}
.langPocketToggle:hover { background: var(--bg-raised); }
.langPocketLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.langPocket {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
animation: fadeIn 0.1s ease both;
}
/* ── Source group (multi-lang) ─────────────────────────────────────────── */
.splitItemGroup { } .splitItemGroup { }
.splitItemGroupOpen { background: var(--bg-raised); } .splitItemGroupOpen { background: var(--bg-raised); }
.groupLangCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
padding: 0px 5px;
flex-shrink: 0;
letter-spacing: var(--tracking-wide);
}
.groupChevron {
color: var(--text-faint);
flex-shrink: 0;
transition: transform 0.2s ease;
}
.splitItemLangOption {
padding-left: var(--sp-5);
background: var(--bg-overlay);
}
.splitItemLangOption:hover { background: var(--bg-raised); }
.langOptionDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--border-strong);
flex-shrink: 0;
}
.splitItemActive .langOptionDot { background: var(--accent-fg); }
</style> </style>
<script module> <script module>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -356,7 +356,7 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}> <button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label} <Play size={12} weight="fill" />{continueChapter.label}
</button> </button>
{/if} {/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
@@ -11,7 +12,7 @@
let search = $state(""); let search = $state("");
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
$effect(() => { onMount(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { sources = d.sources.nodes; }) .then((d) => { sources = d.sources.nodes; })
.catch(console.error) .catch(console.error)
+15 -17
View File
@@ -5,6 +5,8 @@ import type { Manga, Chapter } from "./types";
const APP_ID = "1487894643613106298"; const APP_ID = "1487894643613106298";
const FALLBACK_IMAGE = "moku_logo"; const FALLBACK_IMAGE = "moku_logo";
let sessionStart: number | null = null;
function isPublicUrl(url: string | null | undefined): boolean { function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === "string" && url.startsWith("https://"); return typeof url === "string" && url.startsWith("https://");
} }
@@ -22,15 +24,18 @@ function formatChapter(chapter: Chapter): string {
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`; return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`;
} }
function getTimestamps(): Timestamps {
return new Timestamps(sessionStart ?? Date.now());
}
const BUTTONS = [ const BUTTONS = [
new Button("GitHub", "https://github.com/Youwes09/Moku"), new Button("GitHub", "https://github.com/Youwes09/Moku"),
new Button("Discord", "https://discord.gg/Jq3pwuNqPp"), new Button("Discord", "https://discord.gg/Jq3pwuNqPp"),
]; ];
export async function initRpc(): Promise<void> { export async function initRpc(): Promise<void> {
await start(APP_ID) sessionStart = Date.now();
.then(() => console.log("[discord] RPC started")) await start(APP_ID).catch(() => {});
.catch((e) => console.error("[discord] initRpc failed:", e));
} }
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> { export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
@@ -44,12 +49,10 @@ export async function setReading(manga: Manga, chapter: Chapter): Promise<void>
.setDetails(trunc(manga.title)) .setDetails(trunc(manga.title))
.setState(`${formatChapter(chapter)} · Reading`) .setState(`${formatChapter(chapter)} · Reading`)
.setAssets(assets) .setAssets(assets)
.setTimestamps(new Timestamps(Date.now())); .setTimestamps(getTimestamps());
activity.setButton(BUTTONS); activity.setButton(BUTTONS);
await setActivity(activity) await setActivity(activity).catch(() => {});
.then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter)))
.catch((e) => console.error("[discord] setActivity failed:", e));
} }
export async function setIdle(): Promise<void> { export async function setIdle(): Promise<void> {
@@ -60,22 +63,17 @@ export async function setIdle(): Promise<void> {
const activity = new Activity() const activity = new Activity()
.setDetails("Browsing") .setDetails("Browsing")
.setAssets(assets) .setAssets(assets)
.setTimestamps(new Timestamps(Date.now())); .setTimestamps(getTimestamps());
activity.setButton(BUTTONS); activity.setButton(BUTTONS);
await setActivity(activity) await setActivity(activity).catch(() => {});
.then(() => console.log("[discord] idle"))
.catch((e) => console.error("[discord] setActivity failed (idle):", e));
} }
export async function clearReading(): Promise<void> { export async function clearReading(): Promise<void> {
await clearActivity() await clearActivity().catch(() => {});
.then(() => console.log("[discord] activity cleared"))
.catch((e) => console.error("[discord] clearActivity failed:", e));
} }
export async function destroyRpc(): Promise<void> { export async function destroyRpc(): Promise<void> {
await stop() sessionStart = null;
.then(() => console.log("[discord] RPC stopped")) await stop().catch(() => {});
.catch((e) => console.error("[discord] destroyRpc failed:", e));
} }
+3
View File
@@ -12,6 +12,7 @@ export interface Keybinds {
togglePageStyle: string; togglePageStyle: string;
toggleFullscreen: string; toggleFullscreen: string;
openSettings: string; openSettings: string;
toggleBookmark: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
@@ -26,6 +27,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
togglePageStyle: "q", togglePageStyle: "q",
toggleFullscreen: "f", toggleFullscreen: "f",
openSettings: "o", openSettings: "o",
toggleBookmark: "m",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
@@ -40,6 +42,7 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
togglePageStyle: "Toggle page style", togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen", toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings", openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
}; };
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
+17
View File
@@ -187,6 +187,23 @@ export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath { query GetDownloadsPath {
settings { settings {
downloadsPath downloadsPath
localSourcePath
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
} }
} }
`; `;
+59 -11
View File
@@ -8,29 +8,77 @@ export function cn(...inputs: ClassValue[]) {
// ── NSFW genre filtering ────────────────────────────────────────────────────── // ── NSFW genre filtering ──────────────────────────────────────────────────────
/** /**
* Genre tags that indicate adult/mature content. * Default substrings used when no user-configured list is available.
* Checked case-insensitively against each manga's genre array. * The Settings > Content tab lets users add/remove entries from this list,
* Extend this set if additional tags need to be covered. * which is stored as settings.nsfwFilteredTags.
*/ */
const NSFW_GENRE_TAGS = new Set([ export const DEFAULT_NSFW_TAGS = [
"adult", "adult",
"mature", "mature",
"hentai", "hentai",
"ecchi", "ecchi",
"erotica", "erotic", // catches "erotica", "erotic content", "erotic manga"
"pornographic", "pornograph", // catches "pornographic", "pornography"
"18+", "18+",
"smut", "smut",
"lemon", "lemon",
"explicit", "explicit",
]); "sexual violence",
];
/** /**
* Returns true if the manga carries at least one genre tag that is considered * Returns true if the manga carries at least one genre tag matching any of
* adult/mature. Used to enforce the `showNsfw` setting across all views. * the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
*/ */
export function isNsfwManga(manga: { genre?: string[] | null }): boolean { export function isNsfwManga(
return (manga.genre ?? []).some((g) => NSFW_GENRE_TAGS.has(g.toLowerCase().trim())); manga: { genre?: string[] | null },
tags: string[] = DEFAULT_NSFW_TAGS,
): boolean {
return (manga.genre ?? []).some((g) => {
const normalized = g.toLowerCase().trim();
return tags.some((sub) => normalized.includes(sub));
});
}
/**
* Single authoritative NSFW gate used by all views.
*
* Returns true when the manga should be HIDDEN. Checks in order:
* 1. showNsfw disabled globally skip everything, hide by source flag or genre match.
* 2. Source is in blockedSourceIds always hide regardless of showNsfw.
* 3. Source is in allowedSourceIds always show (bypasses isNsfw flag only, genre tags still apply).
* 4. Source isNsfw flag hide unless source is allowed.
* 5. Genre tag match hide.
*
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
*/
export function shouldHideNsfw(
manga: {
genre?: string[] | null;
source?: { id?: string; isNsfw?: boolean } | null;
},
settings: {
showNsfw: boolean;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
},
): boolean {
const srcId = manga.source?.id;
// Explicit block always wins, even when showNsfw is on
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
// If NSFW is globally allowed, only explicit blocks apply
if (settings.showNsfw) return false;
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
return isNsfwManga(manga, settings.nsfwFilteredTags);
} }
// ── Source deduplication ────────────────────────────────────────────────────── // ── Source deduplication ──────────────────────────────────────────────────────
+80 -4
View File
@@ -104,10 +104,21 @@ export interface HistoryEntry {
thumbnailUrl: string; thumbnailUrl: string;
chapterId: number; chapterId: number;
chapterName: string; chapterName: string;
pageNumber: number;
readAt: number; readAt: number;
} }
export interface BookmarkEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
savedAt: number;
/** Optional user label, e.g. "before the fight scene" */
label?: string;
}
/** /**
* ReadLogEntry append-only record of every chapter-completion event. * ReadLogEntry append-only record of every chapter-completion event.
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI), * Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
@@ -202,6 +213,7 @@ export interface Settings {
storageLimitGb: number | null; storageLimitGb: number | null;
markReadOnNext: boolean; markReadOnNext: boolean;
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme; theme: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
@@ -228,6 +240,15 @@ export interface Settings {
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */ /** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
/**
* Content filtering managed via the Content tab in Settings.
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive).
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true.
* nsfwBlockedSourceIds: sources always blocked regardless of tag content.
*/
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */ /** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>; libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>; libraryTabStatus: Record<string, LibraryStatusFilter>;
@@ -236,6 +257,12 @@ export interface Settings {
maxPageWidth?: number; maxPageWidth?: number;
/** @deprecated use uiZoom */ /** @deprecated use uiZoom */
uiScale?: number; uiScale?: number;
/** User-added extra directories to include when scanning storage usage. */
extraScanDirs: string[];
/** Cached downloads path from Suwayomi, kept in sync on storage tab load. */
serverDownloadsPath: string;
/** Cached local source path from Suwayomi, kept in sync on storage tab load. */
serverLocalSourcePath: string;
} }
@@ -270,6 +297,7 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null, storageLimitGb: null,
markReadOnNext: true, markReadOnNext: true,
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true,
theme: "dark", theme: "dark",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
@@ -295,8 +323,14 @@ export const DEFAULT_SETTINGS: Settings = {
customThemes: [], customThemes: [],
hiddenCategoryIds: [], hiddenCategoryIds: [],
defaultLibraryCategoryId: null, defaultLibraryCategoryId: null,
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
libraryTabSort: {}, libraryTabSort: {},
libraryTabStatus: {}, libraryTabStatus: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
}; };
// ── Persistence ─────────────────────────────────────────────────────────────── // ── Persistence ───────────────────────────────────────────────────────────────
@@ -359,6 +393,9 @@ function mergeSettings(saved: any): Settings {
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
}; };
} }
@@ -385,6 +422,11 @@ class Store {
* Capped at 5 000 entries; oldest are trimmed first. * Capped at 5 000 entries; oldest are trimmed first.
*/ */
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []); readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
/**
* bookmarks user-placed markers at a specific page in a chapter.
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
*/
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
readingStats: ReadingStats = $state(mergeStats(saved)); readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
@@ -430,12 +472,17 @@ class Store {
$effect(() => { persist({ libraryFilter: this.libraryFilter }); }); $effect(() => { persist({ libraryFilter: this.libraryFilter }); });
$effect(() => { persist({ history: this.history }); }); $effect(() => { persist({ history: this.history }); });
$effect(() => { persist({ readLog: this.readLog }); }); $effect(() => { persist({ readLog: this.readLog }); });
$effect(() => { persist({ bookmarks: this.bookmarks }); });
$effect(() => { persist({ readingStats: this.readingStats }); }); $effect(() => { persist({ readingStats: this.readingStats }); });
$effect(() => { persist({ settings: this.settings }); }); $effect(() => { persist({ settings: this.settings }); });
}); });
} }
openReader(chapter: Chapter, chapterList: Chapter[]) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
// Always set activeManga when provided so the Reader has full manga
// context for Discord RPC (setReading) and any other manga-aware logic.
// Callers that already set store.activeManga directly may omit this arg.
if (manga) this.activeManga = manga;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; this.pageUrls = [];
@@ -465,7 +512,7 @@ class Store {
// ── 1. Update the deduped "continue reading" history ────────────────── // ── 1. Update the deduped "continue reading" history ──────────────────
// Always keep the latest position for each chapter at the top. // Always keep the latest position for each chapter at the top.
if (this.history[0]?.chapterId === entry.chapterId) { if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; this.history[0] = { ...this.history[0], readAt: entry.readAt };
} else { } else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300); this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
} }
@@ -517,6 +564,31 @@ class Store {
}; };
} }
/**
* Add or update a bookmark for the given chapter/page. Only one bookmark
* per chapter is kept adding a second one replaces the first.
*/
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [
bookmark,
// Keep bookmarks from other manga only — one bookmark per manga at a time
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200);
}
removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
}
clearBookmarks() {
this.bookmarks = [];
}
getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId);
}
clearHistory() { this.history = []; this.readLog = []; } clearHistory() { this.history = []; this.readLog = []; }
clearHistoryForManga(mangaId: number) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
@@ -649,7 +721,7 @@ export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ────────────────── // ── Function re-exports — zero call-site changes for actions ──────────────────
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); } export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
export function closeReader() { store.closeReader(); } export function closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); } export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
@@ -677,6 +749,10 @@ export function setSettingsOpen(next: boolean) { store
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function clearDiscoverCache() { store.clearDiscoverCache(); } export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); } export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }