mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Emergency Push + Bookmark Feature (WIP)
This commit is contained in:
@@ -1,21 +1,18 @@
|
|||||||
Major Revisions:
|
Major Revisions:
|
||||||
- Moku + Crossplatform Support (MacOS Remaining)
|
|
||||||
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config)
|
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
- Adjustment in Settings for Theme Editor:
|
|
||||||
- Allow User to Edit/Create Themes
|
|
||||||
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
|
||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||||
- Integrate Download Directory Changes (Settings)
|
- Integrate Download Directory Changes (Settings)
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||||
- MacOS Full-Screen & UI Compatability (TitleBar)
|
- Investigate Zoom (Reader), Appears to have Cutoff, etc.
|
||||||
|
|
||||||
General/Misc Bugs:
|
General/Misc Bugs:
|
||||||
- Fix Highlightable Elements
|
- Fix Highlightable Elements
|
||||||
@@ -25,9 +22,12 @@ General/Misc Bugs:
|
|||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
|
|
||||||
In-Progress:`
|
In-Progress:`
|
||||||
- Fix Reader Chapter Shifts (Glitched Sentinel)
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
- Still Shifts Down after reading ~8+ Chapters?
|
- Fix NSFW Parsing (Appears to not Work???)
|
||||||
- Identify When Chapters are Unloaded, How to Preserve Structure
|
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
|
- Patch Color-Picker to Work Properly
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
Important Commands:
|
||||||
|
|||||||
+129
-14
@@ -248,7 +248,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 +282,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 +296,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 +355,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 +429,18 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 4. PATH fallback ──────────────────────────────────────────────────────
|
||||||
|
// Use `where` on Windows, `which` everywhere else.
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
do_log(log, "[resolve] trying PATH fallback");
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let found = std::process::Command::new("where")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let found = std::process::Command::new("which")
|
let found = std::process::Command::new("which")
|
||||||
.arg(name)
|
.arg(name)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@@ -76,21 +76,14 @@
|
|||||||
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.25–1.5 = Windows scaled display.
|
|
||||||
let platformScale = $state(1.0);
|
let platformScale = $state(1.0);
|
||||||
|
|
||||||
// effectiveZoom = platformScale × uiZoom (user preference, float, default 1.0)
|
|
||||||
// Applied to document.documentElement so the entire UI scales correctly.
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const uiZoom = store.settings.uiZoom ?? 1.5;
|
const uiZoom = store.settings.uiZoom ?? 1.5;
|
||||||
const effective = platformScale * uiZoom;
|
const effective = platformScale * uiZoom;
|
||||||
const pct = effective * 100;
|
const pct = effective * 100;
|
||||||
document.documentElement.style.zoom = `${pct}%`;
|
document.documentElement.style.zoom = `${pct}%`;
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(effective));
|
document.documentElement.style.setProperty("--ui-scale", String(effective));
|
||||||
// visual-vh compensates for the zoom so 100vh-based calculations stay correct.
|
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +129,6 @@
|
|||||||
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;
|
store.settings.uiZoom; platformScale;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
@@ -226,8 +218,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 +227,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,7 +275,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// When the reader closes, show idle presence.
|
// When the reader closes, show idle presence.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!store.activeChapter) {
|
if (!store.activeChapter) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack, tick } from "svelte";
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus } from "phosphor-svelte";
|
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, BookmarkSimple } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, resetChapterProgress } from "../../store/state.svelte";
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||||
import { setReading } from "../../lib/discord";
|
import { setReading } from "../../lib/discord";
|
||||||
import type { FitMode } from "../../store/state.svelte";
|
import type { FitMode } from "../../store/state.svelte";
|
||||||
@@ -185,8 +185,36 @@
|
|||||||
|
|
||||||
const zoomPct = $derived(Math.round(zoom * 100));
|
const zoomPct = $derived(Math.round(zoom * 100));
|
||||||
|
|
||||||
|
// ─── Resume / bookmark ────────────────────────────────────────────────────────
|
||||||
|
// resumePage: fixed at component init from history. Never changes after mount.
|
||||||
|
// We read from history directly (not store.pageNumber) because loadChapter
|
||||||
|
// temporarily resets store.pageNumber to 1 during the fetch.
|
||||||
|
const _resumeHistoryPage = store.activeChapter
|
||||||
|
? (store.history.find(h => h.chapterId === store.activeChapter!.id)?.pageNumber ?? 1)
|
||||||
|
: 1;
|
||||||
|
let resumePage = $state(_resumeHistoryPage > 1 ? _resumeHistoryPage : 0);
|
||||||
|
let resumeDismissed = $state(false);
|
||||||
|
// stripResumeReady: flipped to true once the longstrip scroll-to-resume fires.
|
||||||
|
// In single/double mode store.pageNumber drives the banner; in longstrip we
|
||||||
|
// use this flag because store.pageNumber is scroll-observer-driven and may
|
||||||
|
// never exactly equal resumePage after layout shifts from image loading.
|
||||||
|
let stripResumeReady = $state(false);
|
||||||
|
const showResumeBanner = $derived(
|
||||||
|
resumePage > 1 && !resumeDismissed &&
|
||||||
|
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBookmark = $derived(
|
||||||
|
store.activeChapter ? store.bookmarks.find(b => b.chapterId === store.activeChapter!.id) : undefined
|
||||||
|
);
|
||||||
|
const isBookmarked = $derived(!!currentBookmark);
|
||||||
|
|
||||||
|
// In longstrip, always track the visually active chapter for history/RPC —
|
||||||
|
// autoNext only controls nav-button behavior, not which chapter we attribute
|
||||||
|
// progress to. Without this, scrolling into ch48 while ch47 is activeChapter
|
||||||
|
// would record page 28 of ch48 as page 28 of ch47.
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && autoNext && visibleChapterId
|
style === "longstrip" && visibleChapterId
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||||
: store.activeChapter
|
: store.activeChapter
|
||||||
);
|
);
|
||||||
@@ -216,7 +244,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const visibleChunkLastPage = $derived.by(() => {
|
const visibleChunkLastPage = $derived.by(() => {
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
if (style !== "longstrip") return lastPage;
|
||||||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
const chunk = stripChapters.find(c => c.chapterId === chId);
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunk?.urls.length ?? lastPage;
|
||||||
@@ -235,7 +263,7 @@
|
|||||||
|
|
||||||
const stripToRender = $derived(
|
const stripToRender = $derived(
|
||||||
style === "longstrip"
|
style === "longstrip"
|
||||||
? (autoNext && stripChapters.length > 0
|
? (stripChapters.length > 0
|
||||||
? stripChapters
|
? stripChapters
|
||||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
||||||
: []
|
: []
|
||||||
@@ -269,11 +297,18 @@
|
|||||||
pageReady = false;
|
pageReady = false;
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
store.pageUrls = [];
|
store.pageUrls = [];
|
||||||
|
// Snapshot the resume page BEFORE resetting — openReader already set
|
||||||
|
// store.pageNumber to the saved position, but we must not clobber it here.
|
||||||
|
// We reset to 1 as a safe interim value while pages load, then restore
|
||||||
|
// after the fetch completes so the viewer jumps to the right page.
|
||||||
|
const resumeTo = store.pageNumber > 1 ? store.pageNumber : 1;
|
||||||
store.pageNumber = 1;
|
store.pageNumber = 1;
|
||||||
try {
|
try {
|
||||||
const urls = await fetchPages(id, ctrl.signal);
|
const urls = await fetchPages(id, ctrl.signal);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
store.pageUrls = urls;
|
store.pageUrls = urls;
|
||||||
|
// Clamp the resume page to actual page count (in case history is stale).
|
||||||
|
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
pageReady = true;
|
pageReady = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -284,20 +319,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
// ─── Strip initialisation ─────────────────────────────────────────────────────
|
||||||
|
// IMPORTANT: do NOT read store.pageNumber here — it's updated by the scroll
|
||||||
|
// observer on every scroll event, which would re-run this effect continuously
|
||||||
|
// and reset stripChapters/scroll on every pixel scrolled (the "snap" bug).
|
||||||
|
// Resume page is read from the fixed `resumePage` $state instead, which is
|
||||||
|
// captured once at component init from history and never changes.
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||||
const ch = store.activeChapter;
|
const ch = store.activeChapter;
|
||||||
const urls = store.pageUrls;
|
const urls = store.pageUrls;
|
||||||
|
// resumePage is a $state set once from history — not reactive to scroll.
|
||||||
|
const targetPg = untrack(() => resumePage);
|
||||||
appending = false;
|
appending = false;
|
||||||
if (autoNext) {
|
// Always populate stripChapters in longstrip — it's needed for infinite
|
||||||
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
// scroll appending. autoNext only controls whether the chapter header
|
||||||
visibleChapterId = ch.id;
|
// and visible-chapter tracking update as you scroll between chapters.
|
||||||
} else {
|
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||||
stripChapters = [];
|
visibleChapterId = ch.id;
|
||||||
visibleChapterId = null;
|
// Wait for Svelte to flush the new img elements into the DOM, then scroll.
|
||||||
}
|
// If resuming mid-chapter (targetPg > 1), force-load preceding images so
|
||||||
if (containerEl) containerEl.scrollTop = 0;
|
// their heights are in layout, then scrollIntoView on the target image.
|
||||||
|
tick().then(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
if (targetPg > 1) {
|
||||||
|
const chId = ch.id;
|
||||||
|
const scrollToResumePage = () => {
|
||||||
|
const target = containerEl.querySelector<HTMLImageElement>(
|
||||||
|
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
|
||||||
|
);
|
||||||
|
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
||||||
|
|
||||||
|
// Eager-load all images up to the target so their heights are known.
|
||||||
|
containerEl.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`)
|
||||||
|
.forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
||||||
|
|
||||||
|
const doScroll = () => {
|
||||||
|
target.scrollIntoView({ block: "start" });
|
||||||
|
stripResumeReady = true;
|
||||||
|
};
|
||||||
|
if (target.complete && target.naturalHeight > 0) { doScroll(); }
|
||||||
|
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
||||||
|
};
|
||||||
|
scrollToResumePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
containerEl.scrollTop = 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,9 +402,6 @@
|
|||||||
let stripChaptersRef: StripChapter[] = [];
|
let stripChaptersRef: StripChapter[] = [];
|
||||||
$effect(() => { stripChaptersRef = stripChapters; });
|
$effect(() => { stripChaptersRef = stripChapters; });
|
||||||
|
|
||||||
let autoNextRef = false;
|
|
||||||
$effect(() => { autoNextRef = autoNext; });
|
|
||||||
|
|
||||||
function setupScrollTracking(): () => void {
|
function setupScrollTracking(): () => void {
|
||||||
if (!containerEl || style !== "longstrip") return () => {};
|
if (!containerEl || style !== "longstrip") return () => {};
|
||||||
|
|
||||||
@@ -362,7 +427,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activePage !== null) store.pageNumber = activePage;
|
if (activePage !== null) store.pageNumber = activePage;
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
if (activeChId && activeChId !== visibleChapterId) {
|
||||||
|
// Crossed into a new chapter — reset the previous chapter's resume
|
||||||
|
// position to page 1 so reopening it starts fresh. The history entry
|
||||||
|
// itself is kept so it still appears in the continue-reading UI.
|
||||||
|
if (visibleChapterId) resetChapterProgress(visibleChapterId);
|
||||||
|
visibleChapterId = activeChId;
|
||||||
|
}
|
||||||
|
|
||||||
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
||||||
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
|
||||||
@@ -377,7 +448,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onScrollAppend() {
|
function onScrollAppend() {
|
||||||
if (!autoNextRef) return;
|
// Infinite scroll always active in longstrip — autoNext only controls the
|
||||||
|
// nav-button chapter transition behavior, not scroll-triggered appending.
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.80) appendNextChapter();
|
if (pct >= 0.80) appendNextChapter();
|
||||||
}
|
}
|
||||||
@@ -577,6 +649,24 @@
|
|||||||
restoreZoomAnchor();
|
restoreZoomAnchor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleBookmark() {
|
||||||
|
const ch = store.activeChapter;
|
||||||
|
const manga = store.activeManga;
|
||||||
|
if (!ch || !manga) return;
|
||||||
|
if (isBookmarked) {
|
||||||
|
removeBookmark(ch.id);
|
||||||
|
} else {
|
||||||
|
addBookmark({
|
||||||
|
mangaId: manga.id,
|
||||||
|
mangaTitle: manga.title,
|
||||||
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterName: ch.name,
|
||||||
|
pageNumber: store.pageNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function cycleStyle() {
|
function cycleStyle() {
|
||||||
@@ -639,6 +729,7 @@
|
|||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
@@ -772,6 +863,12 @@
|
|||||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||||
>
|
>
|
||||||
|
{#if showResumeBanner}
|
||||||
|
<div class="resume-banner" role="status">
|
||||||
|
<span>Resumed from page {resumePage}</span>
|
||||||
|
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -860,6 +957,7 @@
|
|||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||||
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); }
|
.ch-sep { color: var(--text-faint); }
|
||||||
@@ -936,5 +1034,25 @@
|
|||||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
||||||
|
/* ── Resume banner ───────────────────────────────────────────────────────── */
|
||||||
|
.resume-banner {
|
||||||
|
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg); padding: 6px var(--sp-3);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary); z-index: 20;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
animation: scaleIn 0.15s ease both;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.resume-dismiss {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
font-size: 9px; color: var(--text-faint);
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+11
-21
@@ -5,7 +5,7 @@ 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; // ← captured once on init
|
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://");
|
||||||
@@ -34,10 +34,8 @@ const BUTTONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export async function initRpc(): Promise<void> {
|
export async function initRpc(): Promise<void> {
|
||||||
sessionStart = Date.now(); // ← set once here
|
sessionStart = Date.now();
|
||||||
await start(APP_ID)
|
await start(APP_ID).catch(() => {});
|
||||||
.then(() => console.log("[discord] RPC started"))
|
|
||||||
.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> {
|
||||||
@@ -51,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(getTimestamps()); // ← reuses session start
|
.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> {
|
||||||
@@ -67,23 +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(getTimestamps()); // ← reuses session start
|
.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> {
|
||||||
sessionStart = null; // ← clean up on stop
|
sessionStart = null;
|
||||||
await stop()
|
await stop().catch(() => {});
|
||||||
.then(() => console.log("[discord] RPC stopped"))
|
}
|
||||||
.catch((e) => console.error("[discord] destroyRpc failed:", e));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+59
-11
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -108,6 +108,18 @@ export interface HistoryEntry {
|
|||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookmarkEntry {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
pageNumber: number;
|
||||||
|
savedAt: number;
|
||||||
|
/** Optional user label, e.g. "before the fight scene" */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadLogEntry — append-only record of every chapter-completion event.
|
* ReadLogEntry — append-only record of every chapter-completion event.
|
||||||
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
||||||
@@ -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>;
|
||||||
@@ -295,6 +316,9 @@ 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: {},
|
||||||
};
|
};
|
||||||
@@ -359,6 +383,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 +412,11 @@ class Store {
|
|||||||
* Capped at 5 000 entries; oldest are trimmed first.
|
* Capped at 5 000 entries; oldest are trimmed first.
|
||||||
*/
|
*/
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
|
/**
|
||||||
|
* bookmarks — user-placed markers at a specific page in a chapter.
|
||||||
|
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
|
||||||
|
*/
|
||||||
|
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
|
||||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||||
settings: Settings = $state(mergeSettings(saved));
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
|
|
||||||
@@ -430,16 +462,26 @@ class Store {
|
|||||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||||
$effect(() => { persist({ history: this.history }); });
|
$effect(() => { persist({ history: this.history }); });
|
||||||
$effect(() => { persist({ readLog: this.readLog }); });
|
$effect(() => { persist({ readLog: this.readLog }); });
|
||||||
|
$effect(() => { persist({ bookmarks: this.bookmarks }); });
|
||||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||||
$effect(() => { persist({ settings: this.settings }); });
|
$effect(() => { persist({ settings: this.settings }); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||||
|
// Always set activeManga when provided so the Reader has full manga
|
||||||
|
// context for Discord RPC (setReading) and any other manga-aware logic.
|
||||||
|
// Callers that already set store.activeManga directly may omit this arg.
|
||||||
|
if (manga) this.activeManga = manga;
|
||||||
this.activeChapter = chapter;
|
this.activeChapter = chapter;
|
||||||
this.activeChapterList = chapterList;
|
this.activeChapterList = chapterList;
|
||||||
this.pageUrls = [];
|
this.pageUrls = [];
|
||||||
this.pageNumber = 1;
|
// Resume from the last saved position if history has one for this chapter.
|
||||||
|
// history[n].pageNumber is kept up-to-date by the progress $effect in
|
||||||
|
// Reader.svelte as the user pages through, so this is always the last page
|
||||||
|
// they were on — not just the page they started from.
|
||||||
|
const saved = this.history.find(h => h.chapterId === chapter.id);
|
||||||
|
this.pageNumber = (saved && saved.pageNumber > 1) ? saved.pageNumber : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReader() {
|
closeReader() {
|
||||||
@@ -517,7 +559,38 @@ class Store {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a bookmark for the given chapter/page. Only one bookmark
|
||||||
|
* per chapter is kept — adding a second one replaces the first.
|
||||||
|
*/
|
||||||
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||||
|
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
|
||||||
|
this.bookmarks = [
|
||||||
|
bookmark,
|
||||||
|
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||||
|
].slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(chapterId: number) {
|
||||||
|
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBookmark(chapterId: number): BookmarkEntry | undefined {
|
||||||
|
return this.bookmarks.find(b => b.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
clearHistory() { this.history = []; this.readLog = []; }
|
clearHistory() { this.history = []; this.readLog = []; }
|
||||||
|
/**
|
||||||
|
* Reset the resume position for a chapter back to page 1.
|
||||||
|
* Called when the user scrolls past a chapter boundary in longstrip — the
|
||||||
|
* chapter still appears in history (for the continue-reading UI), but
|
||||||
|
* reopening it will start from page 1 instead of resuming mid-chapter.
|
||||||
|
*/
|
||||||
|
resetChapterProgress(chapterId: number) {
|
||||||
|
this.history = this.history.map(h =>
|
||||||
|
h.chapterId === chapterId ? { ...h, pageNumber: 1 } : h
|
||||||
|
);
|
||||||
|
}
|
||||||
clearHistoryForManga(mangaId: number) {
|
clearHistoryForManga(mangaId: number) {
|
||||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||||
@@ -649,10 +722,11 @@ export const store = new Store();
|
|||||||
|
|
||||||
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
||||||
|
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
|
||||||
export function closeReader() { store.closeReader(); }
|
export function closeReader() { store.closeReader(); }
|
||||||
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||||
export function clearHistory() { store.clearHistory(); }
|
export function clearHistory() { store.clearHistory(); }
|
||||||
|
export function resetChapterProgress(chapterId: number) { store.resetChapterProgress(chapterId); }
|
||||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
export function wipeAllData() { store.wipeAllData(); }
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||||
@@ -677,6 +751,9 @@ export function setSettingsOpen(next: boolean) { store
|
|||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||||
|
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||||
|
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||||
|
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
|
||||||
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
|
||||||
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
|
||||||
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
|
||||||
|
|||||||
Reference in New Issue
Block a user