Compare commits

..

6 Commits

Author SHA1 Message Date
Youwes09 82f8a9a36b Fix: Forgot Auto-Bookmark Toggle & NSFW On GenreDrill 2026-03-31 22:55:26 -05:00
Youwes09 4decce9a7f Fix: Reworked Bookmark System & Added Double Page (WIP) 2026-03-31 19:46:11 -05:00
Youwes09 a69d5eacc5 Fix: Improved Loading (WIP) 2026-03-31 11:28:00 -05:00
Youwes09 4959722759 Feat: Change Download Directory (WIP) 2026-03-30 23:14:40 -05:00
Youwes09 35ba0171c7 Fix: Zoom Issue & Sidebar Overflow 2026-03-30 00:26:04 -05:00
Youwes09 d26fa50e76 Chore: Bump to 0.6.1 2026-03-30 00:04:54 -05:00
15 changed files with 1113 additions and 584 deletions
+12 -3
View File
@@ -1,5 +1,4 @@
Major Revisions: Major Revisions:
- Contemplate Anime Support, Add Novel Support (Consumet API)
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) - Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
Minor Revisions: Minor Revisions:
@@ -9,6 +8,8 @@ Minor Revisions:
- 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) - 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) - Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
- Adjustment in Settings for Theme Editor:
- Patch Color-Picker to Work Properly
Priority Bugs: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - Cache ALL Cover Pictures & Details for Manga in Library
@@ -25,8 +26,16 @@ In-Progress:`
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Fix NSFW Parsing (Appears to not Work???) - Fix NSFW Parsing (Appears to not Work???)
- Adjustment in Settings for Theme Editor: - Check & Fix Zoom System
- Patch Color-Picker to Work Properly - Incredibly zoomed in on Windows (Appears to work fine on 1440p)
- Zoom Values are Incorrect
- Global Zoom should only scale Reader UI, not Manga
- Fix Resume-from-Read
- Start on Chapter 46 -> Go all the way to Chapter 47 (Page 28)
- Results in Opening Chapter 46 to take to last page of Chapter (Cache not Cleared).
- Add Event that if different chapter is opened, cache is cleared on all previous chapters.
- Add into Settings
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 9e9590cf8c98b07ca774382491b1d8cfcc1f2151afadbf8e23e2abda0c086c11 sha256: fb01fc1a98499aeb5cf3e464c430a94c78ab1e68f15220ea8f95091f6ca593f2
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+1 -1
View File
@@ -18,7 +18,7 @@
perSystem = { system, lib, ... }: perSystem = { system, lib, ... }:
let let
version = "0.6.0"; version = "0.6.1";
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
+1 -1
View File
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]] [[package]]
name = "moku" name = "moku"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.6.0" version = "0.6.1"
edition = "2021" edition = "2021"
[lib] [lib]
+85 -1
View File
@@ -61,10 +61,14 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() { if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path); return PathBuf::from(downloads_path);
} }
// Mirror Suwayomi-Server's own default: <data_dir>/Tachidesk/downloads
// Windows: %LOCALAPPDATA%\Tachidesk\downloads
// macOS: ~/Library/Application Support/Tachidesk/downloads
// Linux: $XDG_DATA_HOME/Tachidesk/downloads (~/.local/share/Tachidesk/downloads)
let base = std::env::var("XDG_DATA_HOME") let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/"))); .unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads") base.join("Tachidesk").join("downloads")
} }
#[tauri::command] #[tauri::command]
@@ -104,6 +108,82 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the resolved default downloads path for the current platform.
/// This mirrors resolve_downloads_path("") so the frontend can display it.
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
/// Returns true if the given path exists and is a directory.
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
/// Creates a directory and all missing parent directories.
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
/// Moves all content from `src` into `dst`, then removes `src`.
/// Emits `migrate_progress` events: `{ done, total, current }`.
/// Only deletes the source tree after every file is confirmed copied.
#[tauri::command]
async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(()); // nothing to migrate
}
// Count files first so the frontend can show accurate progress
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": 0u64, "total": total, "current": ""
}));
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done,
"total": total,
"current": rel.to_string_lossy()
}));
}
}
// Only remove source after all files are confirmed copied
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
/// Returns the OS/monitor DPI scale factor for the window's current monitor. /// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K, /// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied. /// 1.251.5 on Windows displays with OS-level scaling applied.
@@ -651,6 +731,10 @@ pub fn run() {
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path,
check_path_exists,
create_directory,
migrate_downloads,
spawn_server, spawn_server,
kill_server, kill_server,
get_platform_ui_scale, get_platform_ui_scale,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.6.0", "version": "0.6.1",
"identifier": "dev.moku.app", "identifier": "dev.moku.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+36 -8
View File
@@ -77,14 +77,24 @@
let devSplash = $state(false); let devSplash = $state(false);
let platformScale = $state(1.0); let platformScale = $state(1.0);
let _appliedZoom = -1;
let _vhRafId: number | null = null;
function applyZoom() { function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.5; const uiZoom = store.settings.uiZoom ?? 1.0;
const effective = platformScale * uiZoom; if (uiZoom === _appliedZoom) return;
const pct = effective * 100; _appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`; document.documentElement.style.zoom = `${pct}%`;
document.documentElement.style.setProperty("--ui-scale", String(effective));
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / effective}px`); if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
} }
let prevQueue: DownloadQueueItem[] = []; let prevQueue: DownloadQueueItem[] = [];
@@ -130,7 +140,7 @@
}); });
$effect(() => { $effect(() => {
store.settings.uiZoom; platformScale; void store.settings.uiZoom;
applyZoom(); applyZoom();
}); });
@@ -275,13 +285,31 @@
} }
}); });
// When the reader closes, show idle presence.
$effect(() => { $effect(() => {
if (!store.activeChapter) { if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle(); if (store.settings.discordRpc) setIdle();
} }
}); });
function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
if (e.key === "=" || e.key === "+") {
e.preventDefault();
store.settings.uiZoom = Math.min(2.0, Math.round(((store.settings.uiZoom ?? 1.0) + 0.1) * 10) / 10);
} else if (e.key === "-") {
e.preventDefault();
store.settings.uiZoom = Math.max(0.5, Math.round(((store.settings.uiZoom ?? 1.0) - 0.1) * 10) / 10);
} else if (e.key === "0") {
e.preventDefault();
store.settings.uiZoom = 1.0;
}
}
$effect(() => {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
});
function handleRetry() { function handleRetry() {
failed = false; failed = false;
notConfigured = false; notConfigured = false;
@@ -306,7 +334,7 @@
onRetry={handleRetry} onRetry={handleRetry}
onBypass={handleBypass} /> onBypass={handleBypass} />
{:else} {:else}
<div class="root"> <div id="app-shell" class="root">
{#if idle && !store.activeChapter} {#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true} <SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; resetIdle(); }} /> onDismiss={() => { idle = false; resetIdle(); }} />
+6 -5
View File
@@ -50,19 +50,20 @@
</aside> </aside>
<style> <style>
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; } .root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; } .logo { width: 80px; height: 80px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; } .logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); } .nav { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); } .nav::-webkit-scrollbar { display: none; }
.tab { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); background: var(--accent-muted); } .tab.active { color: var(--accent-fg); background: var(--accent-muted); }
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); } .tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); } .bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); } .settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } .settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+90 -41
View File
@@ -17,8 +17,8 @@
}); });
const icons: Record<Toast["kind"], string> = { const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", success: "M20 6L9 17l-5-5",
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z", info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
download: "M12 3v13M7 11l5 5 5-5M5 21h14", download: "M12 3v13M7 11l5 5 5-5M5 21h14",
}; };
@@ -27,10 +27,15 @@
{#if store.toasts.length} {#if store.toasts.length}
<div class="toaster" aria-live="polite"> <div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)} {#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert"> <div
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div>
<span class="icon"> <span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" <svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]} /> <path d={icons[t.kind]} />
</svg> </svg>
</span> </span>
@@ -38,12 +43,6 @@
<p class="title">{t.title}</p> <p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if} {#if t.body}<p class="sub">{t.body}</p>{/if}
</div> </div>
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -51,41 +50,91 @@
<style> <style>
.toaster { .toaster {
position: fixed; bottom: var(--sp-5); right: var(--sp-5); position: fixed;
z-index: 9999; display: flex; flex-direction: column; bottom: var(--sp-5);
gap: var(--sp-2); pointer-events: none; max-width: 320px; right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: 300px;
} }
.toast { .toast {
display: flex; align-items: flex-start; gap: var(--sp-2); display: flex;
padding: var(--sp-2) var(--sp-3); align-items: center;
border-radius: var(--radius-lg); gap: var(--sp-2);
border: 1px solid var(--border-base); padding: 10px var(--sp-3) 10px 0;
border-radius: var(--radius-md);
background: var(--bg-raised); background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08); border: 1px solid var(--border-dim);
pointer-events: all; min-width: 220px; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both; pointer-events: all;
min-width: 200px;
overflow: hidden;
cursor: pointer;
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 0.15s ease, transform 0.15s ease;
} }
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); } .toast:hover { opacity: 0.85; transform: translateX(-2px); }
to { opacity: 1; transform: translateX(0) scale(1); } .toast:active { transform: translateX(0) scale(0.98); }
@keyframes slideIn {
from { opacity: 0; transform: translateX(16px) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
} }
.toast-success { border-color: var(--accent-dim); }
.toast-success .icon { color: var(--accent-fg); } .accent-bar {
.toast-error { border-color: var(--color-error); } width: 3px;
.toast-error .icon { color: var(--color-error); } align-self: stretch;
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); } flex-shrink: 0;
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); } border-radius: 0 2px 2px 0;
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } margin-right: 2px;
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; } }
.toast-success .accent-bar { background: var(--accent-fg); }
.toast-error .accent-bar { background: var(--color-error); }
.toast-info .accent-bar { background: var(--text-faint); }
.toast-download .accent-bar { background: var(--accent-fg); }
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success .icon { color: var(--accent-fg); }
.toast-error .icon { color: var(--color-error); }
.toast-info .icon { color: var(--text-muted); }
.toast-download .icon { color: var(--accent-fg); }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: var(--text-xs);
font-family: var(--font-ui);
color: var(--text-secondary);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.3;
}
.sub { .sub {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); font-family: var(--font-ui);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.close {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
</style> </style>
+3 -3
View File
@@ -4,7 +4,7 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte"; import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source, Category } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -47,9 +47,9 @@
let abortCtrl: AbortController | null = null; let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => { const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
const libIds = new Set(libMatches.map((m) => m.id)); const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]); return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
}); });
const visibleItems = $derived(filtered.slice(0, visibleCount)); const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length); const hasMoreVisible = $derived(visibleCount < filtered.length);
File diff suppressed because it is too large Load Diff
+455 -99
View File
@@ -7,7 +7,7 @@
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER, GET_SOURCES } from "../../lib/queries"; import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER, GET_SOURCES } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries"; import { GET_DOWNLOADS_PATH, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import type { Category, Source } from "../../lib/types"; import type { Category, Source } from "../../lib/types";
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte"; import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
import { cache } from "../../lib/cache"; import { cache } from "../../lib/cache";
@@ -76,13 +76,212 @@
let storageError: string | null = $state(null); let storageError: string | null = $state(null);
let clearing = $state(false); let clearing = $state(false);
let cleared = $state(false); let cleared = $state(false);
// ── External server detection ─────────────────────────────────────────────────
// A server is "external" if its URL doesn't point to localhost — in that case we
// cannot invoke Tauri commands against its filesystem (path validation, disk usage,
// migration). We can still read the server's paths via GraphQL, but we must never
// overwrite the server's download directory config without the user explicitly asking.
const isExternalServer = $derived.by(() => {
const url = (store.settings.serverUrl ?? "http://localhost:4567").toLowerCase().trim();
try {
const host = new URL(url).hostname;
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
} catch { return false; }
});
// ── Download path editing ────────────────────────────────────────────────────
let downloadsPathInput = $state(store.settings.serverDownloadsPath ?? "");
let localSourcePathInput = $state(store.settings.serverLocalSourcePath ?? "");
let pathsSaving = $state(false);
let pathsError: string | null = $state(null);
let pathsFieldError: { dl?: string; loc?: string } = $state({});
let pathsSaved = $state(false);
// The actual resolved default path from Rust — shown as placeholder + scanned when dl path is empty.
// Only meaningful for local servers — Tauri can't stat a remote filesystem.
// Re-fetches reactively when the server URL changes (e.g. user switches to local).
let defaultDownloadsPath = $state("");
$effect(() => {
if (!isExternalServer) {
invoke<string>("get_default_downloads_path").then(p => { defaultDownloadsPath = p; });
} else {
defaultDownloadsPath = "";
}
});
// The last confirmed server paths — used to detect a change requiring migration
let confirmedDownloadsPath = $state(store.settings.serverDownloadsPath ?? "");
let confirmedLocalSourcePath = $state(store.settings.serverLocalSourcePath ?? "");
// ── Migration state ──────────────────────────────────────────────────────────
let migrateFrom: string | null = $state(null); // old path that has content
let migrateTo: string | null = $state(null); // new path
let migrating = $state(false);
let migrateProgress: { done: number; total: number; current: string } | null = $state(null);
let migrateError: string | null = $state(null);
let migrateUnlisten: (() => void) | null = null;
// ── Extra scan directories (local-only, stored in app settings) ──────────────
let extraScanDirs: string[] = $state([...(store.settings.extraScanDirs ?? [])]);
let newScanDir = $state("");
let multiStorageInfos: (StorageInfo & { label: string })[] = $state([]);
let advStorageOpen = $state(false);
async function fetchStorage() { async function fetchStorage() {
storageLoading = true; storageError = null; storageLoading = true; storageError = null;
try { try {
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH); // Always pull the current paths from the server via GQL — works for local and external.
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath }); const pathData = await gql<{ settings: { downloadsPath: string; localSourcePath: string } }>(GET_DOWNLOADS_PATH);
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); } const dl = pathData.settings.downloadsPath ?? "";
finally { storageLoading = false; } const loc = pathData.settings.localSourcePath ?? "";
downloadsPathInput = dl;
localSourcePathInput = loc;
confirmedDownloadsPath = dl;
confirmedLocalSourcePath = loc;
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
// Disk usage scanning uses Tauri invoke — only possible when the server is local.
// For external servers we display the paths pulled above but skip the filesystem scan.
if (isExternalServer) {
multiStorageInfos = []; storageInfo = null; return;
}
// When dl is empty the server uses the default path — scan that instead
const effectiveDl = dl || defaultDownloadsPath;
const dirsToScan: { path: string; label: string }[] = [];
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? "Downloads" : "Downloads (default)" });
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: "Local source" });
for (const p of extraScanDirs) {
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p });
}
if (dirsToScan.length === 0) {
multiStorageInfos = []; storageInfo = null; return;
}
const results = await Promise.allSettled(
dirsToScan.map(d =>
invoke<StorageInfo>("get_storage_info", { downloadsPath: d.path })
.then(info => ({ ...info, label: d.label }))
)
);
multiStorageInfos = results
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === "fulfilled")
.map(r => r.value);
storageInfo = multiStorageInfos[0] ?? null;
} catch (e: any) {
storageError = e instanceof Error ? e.message : String(e);
} finally {
storageLoading = false;
}
}
/** Validate a path exists on disk. Returns error string or null.
* Only runs for local servers — we can't stat a remote filesystem via Tauri. */
async function validatePath(path: string): Promise<string | null> {
if (!path.trim()) return null; // empty = use default, always valid
if (isExternalServer) return null; // can't check remote paths locally
try {
const exists = await invoke<boolean>("check_path_exists", { path: path.trim() });
return exists ? null : "Directory does not exist";
} catch {
return "Could not check path";
}
}
/** Create a directory on disk via Tauri. Only valid for local servers. */
async function createDirectory(path: string): Promise<void> {
if (isExternalServer) throw new Error("Cannot create directories on an external server");
await invoke("create_directory", { path });
}
async function savePaths() {
const dl = downloadsPathInput.trim();
const loc = localSourcePathInput.trim();
pathsError = null; pathsFieldError = {};
// Validate paths exist before touching the server (empty = use default = always valid).
// Skipped for external servers — we can't stat their filesystem.
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)]);
if (dlErr || locErr) {
pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) };
return;
}
pathsSaving = true;
try {
// Send each mutation independently — localSourcePath rejects empty string server-side
await gql(SET_DOWNLOADS_PATH, { path: dl });
if (loc) await gql(SET_LOCAL_SOURCE_PATH, { path: loc });
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
// Migration requires local filesystem access — skip for external servers.
if (!isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath;
const newDl = dl || defaultDownloadsPath;
if (newDl && oldDl && newDl !== oldDl) {
const hadContent = await invoke<boolean>("check_path_exists", { path: oldDl });
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl; }
}
}
confirmedDownloadsPath = dl;
confirmedLocalSourcePath = loc;
pathsSaved = true;
setTimeout(() => pathsSaved = false, 2000);
await fetchStorage();
} catch (e: any) {
pathsError = e?.message ?? "Failed to save paths";
} finally {
pathsSaving = false;
}
}
async function startMigration() {
if (!migrateFrom || !migrateTo) return;
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: "" };
// Subscribe to progress events from Tauri
const { listen } = await import("@tauri-apps/api/event");
migrateUnlisten = await listen<{ done: number; total: number; current: string }>(
"migrate_progress",
(e) => { migrateProgress = e.payload; }
);
try {
await invoke("migrate_downloads", { src: migrateFrom, dst: migrateTo });
migrateFrom = null; migrateTo = null; migrateProgress = null;
await fetchStorage();
} catch (e: any) {
migrateError = e?.message ?? "Migration failed";
} finally {
migrating = false;
migrateUnlisten?.(); migrateUnlisten = null;
}
}
function dismissMigration() {
migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null;
}
function addExtraScanDir() {
const dir = newScanDir.trim();
if (!dir || extraScanDirs.includes(dir)) return;
extraScanDirs = [...extraScanDirs, dir];
updateSettings({ extraScanDirs });
newScanDir = "";
fetchStorage();
}
function removeExtraScanDir(path: string) {
extraScanDirs = extraScanDirs.filter(d => d !== path);
updateSettings({ extraScanDirs });
fetchStorage();
} }
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); }); $effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
function handleClearCache() { function handleClearCache() {
@@ -660,13 +859,13 @@
<p class="section-title">Interface Scale</p> <p class="section-title">Interface Scale</p>
<div class="scale-row"> <div class="scale-row">
<input type="range" min={50} max={200} step={5} <input type="range" min={50} max={200} step={5}
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)} value={Math.round((store.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })} oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" /> class="scale-slider" />
<input <input
type="number" min={50} max={200} step={1} type="number" min={50} max={200} step={1}
class="scale-val-input" class="scale-val-input"
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)} value={Math.round((store.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => { oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10); const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 });
@@ -678,11 +877,11 @@
}} }}
/> />
<span class="scale-pct">%</span> <span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.5 })} disabled={(store.settings.uiZoom ?? 1.5) === 1.5} title="Reset"></button> <button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(store.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%"></button>
</div> </div>
<p class="scale-hint"> <p class="scale-hint">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v} {#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.5) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button> <button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each} {/each}
</p> </p>
</div> </div>
@@ -863,18 +1062,18 @@
</div> </div>
</div> </div>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div> <div class="toggle-info"><span class="toggle-label">Page gap</span></div>
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
</label> </label>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Overlay bars</span><span class="toggle-desc">Top and bottom bars float over the page instead of pushing it</span></div> <div class="toggle-info"><span class="toggle-label">Overlay bars</span></div>
<button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Fit &amp; Zoom</p> <p class="section-title">Fit &amp; Zoom</p>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div> <div class="toggle-info"><span class="toggle-label">Default fit mode</span></div>
<div class="select-wrap" id="fit-mode"> <div class="select-wrap" id="fit-mode">
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}> <button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span> <span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
@@ -892,7 +1091,7 @@
<div class="step-row"> <div class="step-row">
<div class="toggle-info"> <div class="toggle-info">
<span class="toggle-label">Default zoom</span> <span class="toggle-label">Default zoom</span>
<span class="toggle-desc">Starting zoom when opening a chapter. 100% = fills the reader.</span> <span class="toggle-desc">100% = fills the reader</span>
</div> </div>
<div class="scale-row"> <div class="scale-row">
<input type="range" min={10} max={400} step={5} <input type="range" min={10} max={400} step={5}
@@ -925,28 +1124,34 @@
{/each} {/each}
</p> </p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div> <div class="toggle-info"><span class="toggle-label">Optimize contrast</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Behaviour</p> <p class="section-title">Behaviour</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div> <div class="toggle-info"><span class="toggle-label">Auto-mark read</span></div>
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
</label> </label>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div> <div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span></div>
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
</label> </label>
{#if !(store.settings.autoNextChapter ?? false)} {#if !(store.settings.autoNextChapter ?? false)}
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div> <div class="toggle-info"><span class="toggle-label">Mark read when skipping</span></div>
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
</label> </label>
{/if} {/if}
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-bookmark</span><span class="toggle-desc">Automatically saves your page position as you read</span></div>
<button role="switch" aria-checked={store.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="toggle" class:on={store.settings.autoBookmark ?? true} onclick={() => {
updateSettings({ autoBookmark: !(store.settings.autoBookmark ?? true) });
}}><span class="toggle-thumb"></span></button>
</label>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div> <div class="toggle-info"><span class="toggle-label">Pages to preload</span></div>
<div class="step-controls"> <div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button> <button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button>
<span class="step-val">{store.settings.preloadPages}</span> <span class="step-val">{store.settings.preloadPages}</span>
@@ -960,7 +1165,7 @@
<div class="section"> <div class="section">
<p class="section-title">Display</p> <p class="section-title">Display</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells may crop cover edges</span></div> <div class="toggle-info"><span class="toggle-label">Crop cover images</span></div>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
@@ -986,15 +1191,15 @@
<div class="section"> <div class="section">
<p class="section-title">History</p> <p class="section-title">History</p>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{store.history.length} entries stored</span></div> <div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{store.history.length} entries</span></div>
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button> <button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear</button>
</div> </div>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"> <div class="toggle-info">
<span class="toggle-label">Full data cleanse</span> <span class="toggle-label">Wipe all data</span>
<span class="toggle-desc">Removes history, stats, completed list, hero pins, and manga links</span> <span class="toggle-desc">History, stats, pins, and manga links</span>
</div> </div>
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button> <button class="danger-btn" onclick={wipeAllData}>Wipe</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1005,7 +1210,7 @@
<div class="step-row"> <div class="step-row">
<div class="toggle-info"> <div class="toggle-info">
<span class="toggle-label">Items per page</span> <span class="toggle-label">Items per page</span>
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span> <span class="toggle-desc">Lower = faster on large libraries</span>
</div> </div>
<div class="step-controls"> <div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button> <button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button>
@@ -1022,21 +1227,21 @@
<div class="section"> <div class="section">
<p class="section-title">Rendering</p> <p class="section-title">Rendering</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div> <div class="toggle-info"><span class="toggle-label">GPU acceleration</span></div>
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Idle / Splash Screen</p> <p class="section-title">Idle / Splash Screen</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div> <div class="toggle-info"><span class="toggle-label">Animated card background</span></div>
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Interface</p> <p class="section-title">Interface</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div> <div class="toggle-info"><span class="toggle-label">Compact sidebar</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
@@ -1045,7 +1250,7 @@
<div class="step-row"> <div class="step-row">
<div class="toggle-info"> <div class="toggle-info">
<span class="toggle-label">Cache entries</span> <span class="toggle-label">Cache entries</span>
<span class="toggle-desc">In-memory request cache for this session (library, sources, genre pages). Cleared on restart.</span> <span class="toggle-desc">In-memory, cleared on restart</span>
</div> </div>
<div class="perf-stat-group"> <div class="perf-stat-group">
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span> <span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
@@ -1098,84 +1303,202 @@
</div> </div>
{:else if tab === "storage"} {:else if tab === "storage"}
<div class="panel"> <div class="panel">
<!-- ── Migration banner ──────────────────────────────────────── -->
{#if migrateFrom && !isExternalServer}
<div class="migrate-banner">
<div class="migrate-banner-body">
<span class="migrate-title">Manga found at previous path move to new location?</span>
<span class="migrate-paths">{migrateFrom} {migrateTo}</span>
{#if migrateProgress && migrateProgress.total > 0}
<div class="migrate-progress">
<div class="migrate-progress-labels">
<span class="migrate-current">{migrateProgress.current}</span>
<span class="migrate-count">{migrateProgress.done} / {migrateProgress.total}</span>
</div>
<div class="migrate-bar"><div class="migrate-bar-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
</div>
{/if}
{#if migrateError}<span class="migrate-error">{migrateError}</span>{/if}
</div>
<div class="migrate-banner-actions">
<button class="sec-action-btn sec-action-primary" onclick={startMigration} disabled={migrating}>
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : "Starting…") : "Move files"}
</button>
<button class="sec-action-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
</div>
</div>
{/if}
<!-- ── Disk Usage ─────────────────────────────────────────────── -->
<div class="section"> <div class="section">
<p class="section-title">Disk Usage</p> <div class="section-title-row"><p class="section-title">Disk Usage</p><button class="sec-action-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? "…" : "↻"}</button></div>
{#if storageLoading}<p class="storage-loading">Reading filesystem</p> {#if storageLoading}
{:else if storageError}<p class="storage-loading" style="color:var(--color-error)">{storageError}</p> <p class="storage-loading">Reading filesystem…</p>
{:else if storageInfo} {:else if storageError}
{@const mangaBytes = storageInfo.manga_bytes} <p class="storage-loading" style="color:var(--color-error)">{storageError}</p>
{@const totalBytes = storageInfo.total_bytes} {:else if isExternalServer}
{@const freeBytes = storageInfo.free_bytes} <p class="storage-loading">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
{@const limitGb = store.settings.storageLimitGb ?? null} {:else if multiStorageInfos.length > 0}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null} {#each multiStorageInfos as info}
{@const available = mangaBytes + freeBytes} {@const limitGb = store.settings.storageLimitGb ?? null}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available} {@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0} {@const available = info.manga_bytes + info.free_bytes}
<div class="storage-bar-wrap"> {@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
<div class="storage-bar"> {@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div> <div class="storage-bar-wrap">
<div class="storage-bar-header">
<span class="storage-bar-label">{info.label}</span>
<span class="storage-bar-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
</div>
<div class="storage-bar">
<div class="storage-bar-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
</div>
<div class="storage-bar-labels">
<span class="storage-path-note" style="margin:0">{info.path}</span>
<span class="storage-bar-free">{fmtBytes(info.free_bytes)} free</span>
</div>
</div> </div>
<div class="storage-bar-labels"> {/each}
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span> {:else}
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span> <p class="storage-loading">No download path configured.</p>
</div>
</div>
<div class="storage-legend">
<div class="storage-legend-row"><span class="storage-dot storage-dot-manga"></span><span class="storage-legend-label">Downloaded manga</span><span class="storage-legend-val">{fmtBytes(mangaBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-free"></span><span class="storage-legend-label">Drive free</span><span class="storage-legend-val">{fmtBytes(freeBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-app"></span><span class="storage-legend-label">Drive total</span><span class="storage-legend-val">{fmtBytes(totalBytes)}</span></div>
</div>
<p class="storage-path-note">{storageInfo.path}</p>
{/if} {/if}
</div> </div>
<!-- ── Downloads path ─────────────────────────────────────────── -->
<div class="section"> <div class="section">
<p class="section-title">Cache</p> <p class="section-title">Downloads Path</p>
<div class="step-row"> {#if isExternalServer}
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div> <p class="toggle-desc" style="display:block;padding:0 var(--sp-3) var(--sp-2)">
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}> Connected to an external server. The path below is read from the server — changes here will update the server's config directly. Make sure the path is valid on the server's filesystem.
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"} </p>
</button> {/if}
<div class="path-row">
<input
class="text-input path-input"
class:path-input-error={!!pathsFieldError.dl}
bind:value={downloadsPathInput}
placeholder={isExternalServer ? "Server default" : (defaultDownloadsPath || "Default location")}
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined }; }}
/>
<div class="path-actions">
{#if pathsFieldError.dl}
<span class="path-field-error">{pathsFieldError.dl}</span>
{#if !isExternalServer}
<button class="sec-action-btn" onclick={async () => {
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined }; }
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? "Failed" }; }
}}>Create</button>
{/if}
{/if}
{#if pathsError}<span class="path-field-error">{pathsError}</span>{/if}
<button class="sec-action-btn sec-action-primary" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
</div>
</div> </div>
</div> </div>
<!-- ── Storage Limit ───────────────────────────────────────────── -->
<div class="section"> <div class="section">
<p class="section-title">Storage Limit</p> <p class="section-title">Storage Limit</p>
<div class="step-row"> <div class="step-row">
<div class="toggle-info"> <div class="toggle-info">
<span class="toggle-label">Limit download storage</span> <span class="toggle-label">Warn when limit is reached</span>
<span class="toggle-desc"> <span class="toggle-desc">{store.settings.storageLimitGb === null ? "No limit set" : `Warn above ${store.settings.storageLimitGb} GB`}</span>
{store.settings.storageLimitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
</span>
</div> </div>
{#if store.settings.storageLimitGb === null} {#if store.settings.storageLimitGb === null}
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)" <button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={() => updateSettings({ storageLimitGb: 10 })}> onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
Set limit
</button>
{:else} {:else}
<div class="step-controls"> <div class="step-controls">
<button class="step-btn" <button class="step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })} disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button>
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })} <input type="number" min="1" step="1" class="storage-limit-input" value={store.settings.storageLimitGb}
disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button> oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }); }} />
<input
type="number" min="1" step="1"
class="storage-limit-input"
value={store.settings.storageLimitGb}
oninput={(e) => {
const n = parseFloat(e.currentTarget.value);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}}
/>
<span class="storage-limit-unit">GB</span> <span class="storage-limit-unit">GB</span>
<button class="step-btn" <button class="step-btn" onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button> <button class="kb-reset" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}></button>
<button class="kb-reset" title="Remove limit"
onclick={() => updateSettings({ storageLimitGb: null })}></button>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- ── Cache ──────────────────────────────────────────────────── -->
<div class="section">
<p class="section-title">Cache</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Webview page image cache</span></div>
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear"}
</button>
</div>
</div>
<!-- ── Advanced (collapsible) ──────────────────────────────────── -->
<div class="section adv-section">
<button class="adv-toggle" onclick={() => advStorageOpen = !advStorageOpen}>
<span class="section-title" style="padding:0">Advanced</span>
<svg class="adv-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if advStorageOpen}
<div class="adv-body">
<!-- Local source -->
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Local source path</span>
<span class="toggle-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
<div style="display:flex;align-items:center;gap:var(--sp-2)">
<input class="text-input" style="width:200px;font-family:monospace;font-size:var(--text-xs);{pathsFieldError.loc?'border-color:var(--color-error)':''}"
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} />
{#if pathsFieldError.loc && !isExternalServer}
<button class="sec-action-btn" onclick={async () => {
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined }; }
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? "Failed" }; }
}}>Create</button>
{/if}
</div>
{#if pathsFieldError.loc}<span style="font-family:var(--font-ui);font-size:10px;color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
</div>
</div>
<!-- Extra scan dirs -->
{#each extraScanDirs as dir}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
<span class="toggle-desc">Extra scan directory</span>
</div>
<button class="danger-btn" onclick={() => removeExtraScanDir(dir)}>Remove</button>
</div>
{/each}
<!-- Add extra dir -->
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Additional scan path</span>
<span class="toggle-desc">Include an extra directory in disk usage readings</span>
</div>
<div style="display:flex;gap:var(--sp-2);align-items:center;flex-shrink:0">
<input class="text-input" style="width:200px;font-family:monospace;font-size:var(--text-xs)"
bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && addExtraScanDir()} />
<button class="sec-action-btn" onclick={addExtraScanDir} disabled={!newScanDir.trim() || extraScanDirs.includes(newScanDir.trim())}>Add</button>
</div>
</div>
<!-- Save -->
<div class="step-row" style="padding-top:0">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
{/if}
</div>
</div> </div>
{:else if tab === "folders"} {:else if tab === "folders"}
<div class="panel"> <div class="panel">
@@ -1244,10 +1567,6 @@
<div class="panel"> <div class="panel">
<div class="section"> <div class="section">
<p class="section-title">Connected Trackers</p> <p class="section-title">Connected Trackers</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2)">
Log in to sync your reading progress with external tracking services.
After connecting, use the Tracking panel inside any manga's detail page.
</p>
{#if trackersError} {#if trackersError}
<div class="tracker-error">{trackersError}</div> <div class="tracker-error">{trackersError}</div>
{/if} {/if}
@@ -1280,9 +1599,7 @@
{:else if oauthTrackerId === tracker.id} {:else if oauthTrackerId === tracker.id}
<div class="oauth-flow"> <div class="oauth-flow">
<p class="oauth-hint"> <p class="oauth-hint">
Your browser opened the {tracker.name} login page. After authorising, Browser opened {tracker.name} login after authorising, copy the full callback URL and paste it below.
you'll land on a Suwayomi page — <strong>copy the full URL from your browser's address bar</strong>
(it starts with <code>https://suwayomi.org/...</code> and contains your token) and paste it below.
</p> </p>
<input <input
class="oauth-input" class="oauth-input"
@@ -1559,14 +1876,14 @@
<div class="section"> <div class="section">
<p class="section-title">Content Filter</p> <p class="section-title">Content Filter</p>
<label class="toggle-row"> <label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Show adult content</span><span class="toggle-desc">When off, sources and manga matching blocked tags are hidden across all views</span></div> <div class="toggle-info"><span class="toggle-label">Show adult content</span><span class="toggle-desc">Sources and manga matching blocked tags are hidden when off</span></div>
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show adult content" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show adult content" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
</label> </label>
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Blocked Genre Tags</p> <p class="section-title">Blocked Genre Tags</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block"> <p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);display:block">
Manga whose genres contain any of these substrings are filtered out. Matching is case-insensitive and partial "erotic" catches "Erotica", "Erotic Content", etc. Manga whose genres contain any of these substrings are filtered out. Case-insensitive, partial match.
</p> </p>
<div class="content-tag-grid"> <div class="content-tag-grid">
{#each (store.settings.nsfwFilteredTags ?? []) as tag} {#each (store.settings.nsfwFilteredTags ?? []) as tag}
@@ -1593,8 +1910,8 @@
</div> </div>
<div class="section"> <div class="section">
<p class="section-title">Source Overrides</p> <p class="section-title">Source Overrides</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block"> <p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);display:block">
<strong>Allow</strong> lets a source through even if it's flagged NSFW (genre tags still apply). <strong>Block</strong> always hides a source regardless of the global setting. Allow lets a source through even if flagged NSFW. Block always hides it.
</p> </p>
<div class="content-source-search-wrap"> <div class="content-source-search-wrap">
<input class="text-input" placeholder="Filter sources…" bind:value={sourceSearch} style="width:100%" /> <input class="text-input" placeholder="Filter sources…" bind:value={sourceSearch} style="width:100%" />
@@ -1700,7 +2017,8 @@
{:else if releases.length === 0} {:else if releases.length === 0}
<p class="storage-loading">No releases found.</p> <p class="storage-loading">No releases found.</p>
{:else} {:else}
<div class="release-list"> <div class="release-list-scroll">
<div class="release-list">
{#each releases as release} {#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)} {@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name} {@const isExpanded = expandedTag === release.tag_name}
@@ -1748,6 +2066,7 @@
{/if} {/if}
</div> </div>
{/each} {/each}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1883,6 +2202,8 @@
.kb-reset:disabled { opacity: 0.3; cursor: default; } .kb-reset:disabled { opacity: 0.3; cursor: default; }
.storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); } .storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); }
.storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); } .storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.storage-bar-header { display: flex; justify-content: space-between; align-items: baseline; }
.storage-bar-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; } .storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; } .storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.storage-bar-fill.warn { background: #d97706; } .storage-bar-fill.warn { background: #d97706; }
@@ -1898,6 +2219,41 @@
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; } .storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); } .storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; } .storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
/* ── Migration banner ───────────────────────────────────────── */
.migrate-banner { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin: 0 0 var(--sp-2); padding: var(--sp-3) var(--sp-4); background: color-mix(in srgb, var(--color-info) 7%, transparent); border: 1px solid color-mix(in srgb, var(--color-info) 22%, transparent); border-radius: var(--radius-md); }
.migrate-banner-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.migrate-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-info); letter-spacing: var(--tracking-wide); }
.migrate-paths { font-family: monospace; font-size: 10px; color: var(--text-faint); word-break: break-all; }
.migrate-error { font-size: var(--text-xs); color: var(--color-error); }
.migrate-progress { display: flex; flex-direction: column; gap: 4px; margin-top: 2px; }
.migrate-progress-labels { display: flex; justify-content: space-between; }
.migrate-current { font-family: monospace; font-size: 10px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
.migrate-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.migrate-bar { height: 3px; background: var(--bg-overlay); border-radius: 2px; overflow: hidden; }
.migrate-bar-fill { height: 100%; background: var(--color-info); border-radius: 2px; transition: width 0.15s; }
.migrate-banner-actions { display: flex; flex-direction: column; gap: var(--sp-1); flex-shrink: 0; align-items: flex-end; }
/* ── Downloads path row ─────────────────────────────────────── */
.path-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.path-input { flex: 1; width: 0 !important; min-width: 0; font-family: monospace !important; font-size: var(--text-xs) !important; }
.path-input-error { border-color: var(--color-error) !important; }
.path-field-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.path-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
/* ── Advanced collapsible ───────────────────────────────────── */
.adv-section { padding-bottom: var(--sp-1); }
.adv-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: var(--sp-2) var(--sp-3); background: none; border: none; cursor: pointer; border-radius: var(--radius-md); transition: background var(--t-fast); }
.adv-toggle:hover { background: var(--bg-raised); }
.adv-caret { color: var(--text-faint); transition: transform var(--t-base); flex-shrink: 0; }
.adv-caret.open { transform: rotate(180deg); }
.adv-body { display: flex; flex-direction: column; gap: 1px; padding-top: var(--sp-1); }
/* ── Releases scroll ────────────────────────────────────────── */
.release-list-scroll { max-height: 336px; overflow-y: auto; padding: 0 var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-base) transparent; }
.release-list-scroll::-webkit-scrollbar { width: 4px; }
.release-list-scroll::-webkit-scrollbar-track { background: transparent; }
.release-list-scroll::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 2px; }
.folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); } .folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
.folder-create-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); } .folder-create-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
.folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); } .folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); }
+17
View File
@@ -187,6 +187,23 @@ export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath { query GetDownloadsPath {
settings { settings {
downloadsPath downloadsPath
localSourcePath
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
} }
} }
`; `;
+20 -21
View File
@@ -104,7 +104,6 @@ export interface HistoryEntry {
thumbnailUrl: string; thumbnailUrl: string;
chapterId: number; chapterId: number;
chapterName: string; chapterName: string;
pageNumber: number;
readAt: number; readAt: number;
} }
@@ -214,6 +213,7 @@ export interface Settings {
storageLimitGb: number | null; storageLimitGb: number | null;
markReadOnNext: boolean; markReadOnNext: boolean;
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean;
theme: Theme; theme: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
@@ -257,6 +257,12 @@ export interface Settings {
maxPageWidth?: number; maxPageWidth?: number;
/** @deprecated use uiZoom */ /** @deprecated use uiZoom */
uiScale?: number; uiScale?: number;
/** User-added extra directories to include when scanning storage usage. */
extraScanDirs: string[];
/** Cached downloads path from Suwayomi, kept in sync on storage tab load. */
serverDownloadsPath: string;
/** Cached local source path from Suwayomi, kept in sync on storage tab load. */
serverLocalSourcePath: string;
} }
@@ -291,6 +297,7 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null, storageLimitGb: null,
markReadOnNext: true, markReadOnNext: true,
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true,
theme: "dark", theme: "dark",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
@@ -321,6 +328,9 @@ export const DEFAULT_SETTINGS: Settings = {
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
libraryTabSort: {}, libraryTabSort: {},
libraryTabStatus: {}, libraryTabStatus: {},
extraScanDirs: [],
serverDownloadsPath: "",
serverLocalSourcePath: "",
}; };
// ── Persistence ─────────────────────────────────────────────────────────────── // ── Persistence ───────────────────────────────────────────────────────────────
@@ -476,12 +486,7 @@ class Store {
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
this.pageUrls = []; this.pageUrls = [];
// Resume from the last saved position if history has one for this chapter. this.pageNumber = 1;
// 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() {
@@ -507,7 +512,7 @@ class Store {
// ── 1. Update the deduped "continue reading" history ────────────────── // ── 1. Update the deduped "continue reading" history ──────────────────
// Always keep the latest position for each chapter at the top. // Always keep the latest position for each chapter at the top.
if (this.history[0]?.chapterId === entry.chapterId) { if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; this.history[0] = { ...this.history[0], readAt: entry.readAt };
} else { } else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300); this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
} }
@@ -567,7 +572,8 @@ class Store {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label }; const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [ this.bookmarks = [
bookmark, bookmark,
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId), // Keep bookmarks from other manga only — one bookmark per manga at a time
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200); ].slice(0, 200);
} }
@@ -575,22 +581,15 @@ class Store {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
} }
clearBookmarks() {
this.bookmarks = [];
}
getBookmark(chapterId: number): BookmarkEntry | undefined { getBookmark(chapterId: number): BookmarkEntry | undefined {
return this.bookmarks.find(b => b.chapterId === chapterId); 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);
@@ -726,7 +725,6 @@ export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Man
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); }
@@ -753,6 +751,7 @@ export function resetKeybinds() { store
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 addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); } export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(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); }