mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Feat: Change Download Directory (WIP)
This commit is contained in:
@@ -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
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: e5b4e81c241bfd6940cea0f4815f36ce0f0260fae7249e90d56926b8cafe8016
|
sha256: fb01fc1a98499aeb5cf3e464c430a94c78ab1e68f15220ea8f95091f6ca593f2
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
+85
-1
@@ -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.25–1.5 on Windows displays with OS-level scaling applied.
|
/// 1.25–1.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,
|
||||||
|
|||||||
+36
-6
@@ -78,13 +78,37 @@
|
|||||||
|
|
||||||
let platformScale = $state(1.0);
|
let platformScale = $state(1.0);
|
||||||
|
|
||||||
|
// Track last applied zoom so we only touch the DOM when the value actually changes.
|
||||||
|
let _appliedZoom = -1;
|
||||||
|
let _vhRafId: number | null = null;
|
||||||
|
|
||||||
function applyZoom() {
|
function applyZoom() {
|
||||||
const uiZoom = store.settings.uiZoom ?? 1.0;
|
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||||
const effective = platformScale * uiZoom;
|
// Only touch the DOM when the zoom value has genuinely changed.
|
||||||
const pct = effective * 100;
|
if (uiZoom === _appliedZoom) return;
|
||||||
|
_appliedZoom = uiZoom;
|
||||||
|
|
||||||
|
const pct = uiZoom * 100;
|
||||||
|
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||||
|
|
||||||
|
// Only scale the non-reader shell. The reader mounts as a fixed overlay
|
||||||
|
// and manages its own zoom — applying document-level zoom to it would
|
||||||
|
// double-scale manga images.
|
||||||
|
const shell = document.getElementById("app-shell");
|
||||||
|
if (shell) {
|
||||||
|
(shell as HTMLElement).style.zoom = `${pct}%`;
|
||||||
|
} else {
|
||||||
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`);
|
|
||||||
|
// Defer --visual-vh until after the browser has re-laid-out at the new
|
||||||
|
// zoom level so we read a stable innerHeight, not a mid-transition value.
|
||||||
|
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 +154,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
store.settings.uiZoom; platformScale;
|
// Re-run only when uiZoom actually changes. platformScale is handled
|
||||||
|
// directly inside onScaleChanged so it doesn't trigger spurious re-runs
|
||||||
|
// of this effect on unrelated reactive flushes.
|
||||||
|
void store.settings.uiZoom;
|
||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +245,9 @@
|
|||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
|
// We read the scale factor so onScaleChanged can re-trigger applyZoom when
|
||||||
|
// the window moves to a different-DPI monitor, but we do NOT fold it into
|
||||||
|
// the zoom math — Tauri's WebView already accounts for DPI scaling.
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
|
||||||
applyZoom();
|
applyZoom();
|
||||||
|
|
||||||
@@ -325,7 +355,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(); }} />
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack, tick } from "svelte";
|
import { onMount, untrack, tick } from "svelte";
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, BookmarkSimple } from "phosphor-svelte";
|
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, Bookmark } 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, addBookmark, removeBookmark, resetChapterProgress } from "../../store/state.svelte";
|
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addToast } 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";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AVG_MIN_PER_PAGE = 0.33;
|
const AVG_MIN_PER_PAGE = 0.33;
|
||||||
const MAX_CACHED = 10;
|
const MAX_CACHED = 10;
|
||||||
const READ_LINE_PCT = 0.20;
|
const READ_LINE_PCT = 0.20;
|
||||||
// Zoom step per Ctrl+Wheel tick or keyboard shortcut (5% of viewer width)
|
|
||||||
const ZOOM_STEP = 0.05;
|
const ZOOM_STEP = 0.05;
|
||||||
const ZOOM_MIN = 0.1;
|
const ZOOM_MIN = 0.1;
|
||||||
const ZOOM_MAX = 4.0;
|
const ZOOM_MAX = 4.0;
|
||||||
|
|
||||||
// ─── Page cache ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
const cacheOrder: number[] = [];
|
const cacheOrder: number[] = [];
|
||||||
@@ -30,13 +25,12 @@
|
|||||||
cacheOrder.push(id);
|
cacheOrder.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheEvict(keep: Set<number>) {
|
function cacheClearExcept(keepId: number) {
|
||||||
while (pageCache.size > MAX_CACHED) {
|
for (const id of pageCache.keys()) {
|
||||||
const victim = cacheOrder.find(id => !keep.has(id));
|
if (id !== keepId) pageCache.delete(id);
|
||||||
if (victim === undefined) break;
|
|
||||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
|
||||||
pageCache.delete(victim);
|
|
||||||
}
|
}
|
||||||
|
cacheOrder.length = 0;
|
||||||
|
if (pageCache.has(keepId)) cacheOrder.push(keepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
||||||
@@ -62,8 +56,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Image helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
function preloadImage(url: string) { new Image().src = url; }
|
function preloadImage(url: string) { new Image().src = url; }
|
||||||
|
|
||||||
@@ -90,26 +82,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
||||||
|
|
||||||
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
// ─── Container width (for resolution-based zoom) ──────────────────────────────
|
|
||||||
// Tracked via ResizeObserver so 100% zoom always means "fills the viewer",
|
|
||||||
// regardless of screen resolution or window size.
|
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
// ─── Zoom anchor (longstrip) ──────────────────────────────────────────────────
|
|
||||||
// Before zoom changes the layout we snapshot which image is at the top of the
|
|
||||||
// viewport and how far it is from the top edge. After the DOM re-renders at
|
|
||||||
// the new zoom we scroll back so that same image is at the same visual offset,
|
|
||||||
// preventing the "random page teleport" that occurs when scrollHeight changes.
|
|
||||||
|
|
||||||
let zoomAnchorEl: HTMLElement | null = null;
|
let zoomAnchorEl: HTMLElement | null = null;
|
||||||
let zoomAnchorOffset: number = 0;
|
let zoomAnchorOffset: number = 0;
|
||||||
|
|
||||||
@@ -131,7 +109,6 @@
|
|||||||
if (!zoomAnchorEl || !containerEl) return;
|
if (!zoomAnchorEl || !containerEl) return;
|
||||||
const el = zoomAnchorEl;
|
const el = zoomAnchorEl;
|
||||||
zoomAnchorEl = null;
|
zoomAnchorEl = null;
|
||||||
// Use rAF to wait for the DOM to finish re-laying out after the zoom change.
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
const newRect = el.getBoundingClientRect();
|
const newRect = el.getBoundingClientRect();
|
||||||
@@ -139,8 +116,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── UI state ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let dlOpen = $state(false);
|
let dlOpen = $state(false);
|
||||||
@@ -154,19 +129,13 @@
|
|||||||
let dlBusy = $state(false);
|
let dlBusy = $state(false);
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// ─── Non-reactive bookkeeping ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let markedRead = new Set<number>();
|
let markedRead = new Set<number>();
|
||||||
let appending = false;
|
let appending = false;
|
||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
let loadingId: number | null = null;
|
let loadingId: number | null = null;
|
||||||
let navToken = 0;
|
let navToken = 0;
|
||||||
// Only write history after the user has genuinely moved past the opening page.
|
|
||||||
// Prevents the "started on page 1" entry being saved as last position on close.
|
|
||||||
let hasNavigated = false;
|
let hasNavigated = false;
|
||||||
|
|
||||||
// ─── Derived ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||||
const style = $derived(store.settings.pageStyle ?? "single");
|
const style = $derived(store.settings.pageStyle ?? "single");
|
||||||
@@ -176,53 +145,30 @@
|
|||||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
const lastPage = $derived(store.pageUrls.length);
|
||||||
|
|
||||||
// effectiveWidth: how wide the image should be, in pixels.
|
|
||||||
// = container width × zoom multiplier. Applied as max-width on the viewer
|
|
||||||
// so fit modes (height, screen) can still further constrain the image.
|
|
||||||
const effectiveWidth = $derived(
|
const effectiveWidth = $derived(
|
||||||
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
|
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const zoomPct = $derived(Math.round(zoom * 100));
|
const zoomPct = $derived(Math.round(zoom * 100));
|
||||||
|
|
||||||
// ─── Resume / bookmark ────────────────────────────────────────────────────────
|
let resumePage = $state(0);
|
||||||
// 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);
|
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);
|
let stripResumeReady = $state(false);
|
||||||
const showResumeBanner = $derived(
|
const showResumeBanner = $derived(
|
||||||
resumePage > 1 && !resumeDismissed &&
|
resumePage > 1 && !resumeDismissed &&
|
||||||
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
|
(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" && visibleChapterId
|
style === "longstrip" && visibleChapterId
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||||
: store.activeChapter
|
: store.activeChapter
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Discord RPC ──────────────────────────────────────────────────────────────
|
const currentBookmark = $derived(
|
||||||
// displayChapter already handles both single/double (store.activeChapter) and
|
displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined
|
||||||
// longstrip auto-next (visibleChapterId) — so reacting to it here means RPC
|
);
|
||||||
// updates on every chapter transition regardless of reading mode.
|
const isBookmarked = $derived(!!currentBookmark);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chapter = displayChapter;
|
const chapter = displayChapter;
|
||||||
@@ -275,8 +221,6 @@
|
|||||||
: [store.pageNumber]
|
: [store.pageNumber]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Chapter loading ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ch = store.activeChapter;
|
const ch = store.activeChapter;
|
||||||
if (ch) untrack(() => loadChapter(ch.id));
|
if (ch) untrack(() => loadChapter(ch.id));
|
||||||
@@ -296,18 +240,22 @@
|
|||||||
pageGroups = [];
|
pageGroups = [];
|
||||||
pageReady = false;
|
pageReady = false;
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
|
visibleChapterId = null;
|
||||||
store.pageUrls = [];
|
store.pageUrls = [];
|
||||||
// Snapshot the resume page BEFORE resetting — openReader already set
|
|
||||||
// store.pageNumber to the saved position, but we must not clobber it here.
|
cacheClearExcept(id);
|
||||||
// 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 bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||||
const resumeTo = store.pageNumber > 1 ? store.pageNumber : 1;
|
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||||
|
resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||||
|
resumeDismissed = false;
|
||||||
|
stripResumeReady = false;
|
||||||
|
|
||||||
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);
|
if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
pageReady = true;
|
pageReady = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -318,28 +266,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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);
|
const targetPg = untrack(() => resumePage);
|
||||||
appending = false;
|
appending = false;
|
||||||
// Always populate stripChapters in longstrip — it's needed for infinite
|
|
||||||
// scroll appending. autoNext only controls whether the chapter header
|
|
||||||
// and visible-chapter tracking update as you scroll between chapters.
|
|
||||||
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||||
visibleChapterId = ch.id;
|
visibleChapterId = ch.id;
|
||||||
// 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
|
|
||||||
// their heights are in layout, then scrollIntoView on the target image.
|
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
if (targetPg > 1) {
|
if (targetPg > 1) {
|
||||||
@@ -350,7 +284,6 @@
|
|||||||
);
|
);
|
||||||
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
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}"]`)
|
containerEl.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`)
|
||||||
.forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
.forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
||||||
|
|
||||||
@@ -371,7 +304,24 @@
|
|||||||
|
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||||
|
|
||||||
// ─── Forward append only ──────────────────────────────────────────────────────
|
// When scrolling into an appended chapter in longstrip, check if it has a bookmark
|
||||||
|
// and show the resume banner so the user can jump to their saved page.
|
||||||
|
$effect(() => {
|
||||||
|
const chId = visibleChapterId;
|
||||||
|
if (!chId || style !== "longstrip") return;
|
||||||
|
// Only fire for chapters that weren't the initial load (activeChapter handles its own resume).
|
||||||
|
if (chId === store.activeChapter?.id) return;
|
||||||
|
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
|
||||||
|
if (bookmark && bookmark.pageNumber > 1) {
|
||||||
|
untrack(() => {
|
||||||
|
resumePage = bookmark.pageNumber;
|
||||||
|
resumeDismissed = false;
|
||||||
|
stripResumeReady = true; // banner shows immediately on chapter entry; no scroll needed yet
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
untrack(() => { resumePage = 0; resumeDismissed = false; stripResumeReady = false; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function appendNextChapter() {
|
function appendNextChapter() {
|
||||||
if (appending || !stripChapters.length) return;
|
if (appending || !stripChapters.length) return;
|
||||||
@@ -397,8 +347,6 @@
|
|||||||
.catch(() => { appending = false; });
|
.catch(() => { appending = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scroll tracking ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let stripChaptersRef: StripChapter[] = [];
|
let stripChaptersRef: StripChapter[] = [];
|
||||||
$effect(() => { stripChaptersRef = stripChapters; });
|
$effect(() => { stripChaptersRef = stripChapters; });
|
||||||
|
|
||||||
@@ -428,10 +376,6 @@
|
|||||||
|
|
||||||
if (activePage !== null) store.pageNumber = activePage;
|
if (activePage !== null) store.pageNumber = activePage;
|
||||||
if (activeChId && activeChId !== visibleChapterId) {
|
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;
|
visibleChapterId = activeChId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,8 +392,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onScrollAppend() {
|
function onScrollAppend() {
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
@@ -463,8 +405,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Observer lifecycle ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let cleanupScroll: () => void = () => {};
|
let cleanupScroll: () => void = () => {};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -476,32 +416,24 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Prefetch + cache eviction ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (store.activeChapter && store.activeChapterList.length) {
|
if (store.activeChapter && store.activeChapterList.length) {
|
||||||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const toPin: number[] = [store.activeChapter.id];
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const entry = store.activeChapterList[idx + i];
|
const entry = store.activeChapterList[idx + i];
|
||||||
if (!entry) break;
|
if (!entry) break;
|
||||||
toPin.push(entry.id);
|
|
||||||
fetchPages(entry.id)
|
fetchPages(entry.id)
|
||||||
.then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); })
|
.then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
toPin.push(store.activeChapterList[idx - 1].id);
|
|
||||||
fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
|
fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
|
||||||
}
|
}
|
||||||
cacheEvict(new Set(toPin));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Double-page spread computation ──────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "double" && store.pageUrls.length) {
|
if (style === "double" && store.pageUrls.length) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -523,8 +455,6 @@
|
|||||||
} else { pageGroups = []; }
|
} else { pageGroups = []; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Preload around current page ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
||||||
@@ -532,8 +462,6 @@
|
|||||||
if (behind) preloadImage(behind);
|
if (behind) preloadImage(behind);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Progress / history tracking ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ch = displayChapter ?? store.activeChapter;
|
const ch = displayChapter ?? store.activeChapter;
|
||||||
if (ch && lastPage && store.activeManga) {
|
if (ch && lastPage && store.activeManga) {
|
||||||
@@ -549,14 +477,16 @@
|
|||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (!hasNavigated) return;
|
if (!hasNavigated) return;
|
||||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||||
|
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
|
||||||
|
if (store.settings.bookmarksEnabled ?? true) {
|
||||||
|
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||||
|
}
|
||||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Mark read ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function markChapterRead(id: number) {
|
function markChapterRead(id: number) {
|
||||||
if (markedRead.has(id)) return;
|
if (markedRead.has(id)) return;
|
||||||
markedRead.add(id);
|
markedRead.add(id);
|
||||||
@@ -565,7 +495,7 @@
|
|||||||
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
||||||
if (store.activeManga && chapter) {
|
if (store.activeManga && chapter) {
|
||||||
addHistory(
|
addHistory(
|
||||||
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, pageNumber: pages, readAt: Date.now() },
|
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() },
|
||||||
true, minutes,
|
true, minutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -584,8 +514,6 @@
|
|||||||
if (ch && markOnNext) markChapterRead(ch.id);
|
if (ch && markOnNext) markChapterRead(ch.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Navigation ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function advanceGroup(forward: boolean) {
|
function advanceGroup(forward: boolean) {
|
||||||
if (!pageGroups.length) return;
|
if (!pageGroups.length) return;
|
||||||
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||||||
@@ -631,8 +559,6 @@
|
|||||||
const goNext = $derived(rtl ? goBack : goForward);
|
const goNext = $derived(rtl ? goBack : goForward);
|
||||||
const goPrev = $derived(rtl ? goForward : goBack);
|
const goPrev = $derived(rtl ? goForward : goBack);
|
||||||
|
|
||||||
// ─── Zoom helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function clampZoom(z: number): number {
|
function clampZoom(z: number): number {
|
||||||
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
||||||
}
|
}
|
||||||
@@ -650,11 +576,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleBookmark() {
|
function toggleBookmark() {
|
||||||
const ch = store.activeChapter;
|
const ch = displayChapter;
|
||||||
const manga = store.activeManga;
|
const manga = store.activeManga;
|
||||||
if (!ch || !manga) return;
|
if (!ch || !manga) return;
|
||||||
if (isBookmarked) {
|
if (isBookmarked) {
|
||||||
removeBookmark(ch.id);
|
removeBookmark(ch.id);
|
||||||
|
addToast({ kind: "info", title: "Bookmark removed", duration: 2000 });
|
||||||
} else {
|
} else {
|
||||||
addBookmark({
|
addBookmark({
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
@@ -664,11 +591,10 @@
|
|||||||
chapterName: ch.name,
|
chapterName: ch.name,
|
||||||
pageNumber: store.pageNumber,
|
pageNumber: store.pageNumber,
|
||||||
});
|
});
|
||||||
|
addToast({ kind: "success", title: "Bookmarked", body: `Page ${store.pageNumber} — ${ch.name}`, duration: 2500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Settings toggles ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function cycleStyle() {
|
function cycleStyle() {
|
||||||
const opts = ["single", "longstrip"] as const;
|
const opts = ["single", "longstrip"] as const;
|
||||||
const cur = style === "double" ? "single" : style;
|
const cur = style === "double" ? "single" : style;
|
||||||
@@ -680,8 +606,6 @@
|
|||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── UI helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showUi() {
|
function showUi() {
|
||||||
uiVisible = true;
|
uiVisible = true;
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
@@ -691,7 +615,6 @@
|
|||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!e.ctrlKey) return;
|
if (!e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Each wheel tick adjusts by ZOOM_STEP (5%). Larger deltaY = bigger scroll = same step.
|
|
||||||
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,15 +668,12 @@
|
|||||||
dlBusy = false; dlOpen = false;
|
dlBusy = false; dlOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mount / unmount ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
showUi();
|
showUi();
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
window.addEventListener("wheel", onWheel, { passive: false });
|
||||||
containerEl?.focus({ preventScroll: true });
|
containerEl?.focus({ preventScroll: true });
|
||||||
|
|
||||||
// Track the viewer's actual paint width so zoom is always relative to it.
|
|
||||||
const ro = new ResizeObserver(entries => {
|
const ro = new ResizeObserver(entries => {
|
||||||
containerWidth = entries[0].contentRect.width;
|
containerWidth = entries[0].contentRect.width;
|
||||||
});
|
});
|
||||||
@@ -795,7 +715,6 @@
|
|||||||
<span class="mode-label">{fitLabel}</span>
|
<span class="mode-label">{fitLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- ── Zoom controls ────────────────────────────────────────────────────── -->
|
|
||||||
<div class="zoom-wrap">
|
<div class="zoom-wrap">
|
||||||
<div class="zoom-inline">
|
<div class="zoom-inline">
|
||||||
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||||
@@ -850,6 +769,11 @@
|
|||||||
<button class="mode-btn" onclick={() => dlOpen = true}>
|
<button class="mode-btn" onclick={() => dlOpen = true}>
|
||||||
<Download size={14} weight="light" />
|
<Download size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
{#if store.settings.bookmarksEnabled ?? true}
|
||||||
|
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||||
|
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -865,7 +789,22 @@
|
|||||||
>
|
>
|
||||||
{#if showResumeBanner}
|
{#if showResumeBanner}
|
||||||
<div class="resume-banner" role="status">
|
<div class="resume-banner" role="status">
|
||||||
<span>Resumed from page {resumePage}</span>
|
<span>Bookmark at page {resumePage}</span>
|
||||||
|
{#if style === "longstrip" && visibleChapterId && visibleChapterId !== store.activeChapter?.id}
|
||||||
|
<button class="resume-jump" onclick={() => {
|
||||||
|
const chId = visibleChapterId!;
|
||||||
|
const targetPg = resumePage;
|
||||||
|
const scrollToPage = () => {
|
||||||
|
const target = containerEl.querySelector<HTMLImageElement>(
|
||||||
|
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
|
||||||
|
);
|
||||||
|
if (!target) { requestAnimationFrame(scrollToPage); return; }
|
||||||
|
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
};
|
||||||
|
scrollToPage();
|
||||||
|
resumeDismissed = true;
|
||||||
|
}}>Jump</button>
|
||||||
|
{/if}
|
||||||
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
|
<button class="resume-dismiss" onclick={() => resumeDismissed = true}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -947,7 +886,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform;
|
||||||
|
zoom: calc(1 / var(--ui-zoom, 1));
|
||||||
|
}
|
||||||
.overlay-bars { position: fixed; }
|
.overlay-bars { position: fixed; }
|
||||||
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||||||
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
||||||
@@ -968,7 +909,6 @@
|
|||||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.mode-label { text-transform: capitalize; }
|
.mode-label { text-transform: capitalize; }
|
||||||
|
|
||||||
/* ── Zoom controls ───────────────────────────────────────────────────────── */
|
|
||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||||
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||||
@@ -988,23 +928,12 @@
|
|||||||
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ── Viewer ──────────────────────────────────────────────────────────────── */
|
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
.viewer:focus { outline: none; }
|
.viewer:focus { outline: none; }
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
||||||
|
|
||||||
/*
|
|
||||||
* Fit modes — all constrain within --effective-width (the zoom-adjusted
|
|
||||||
* container width). effectiveWidth is set as a CSS variable on .viewer
|
|
||||||
* so every fit class automatically respects the current zoom level.
|
|
||||||
*
|
|
||||||
* fit-width : fills up to effectiveWidth, never wider
|
|
||||||
* fit-height : constrained to viewport height; never taller, never wider than effectiveWidth
|
|
||||||
* fit-screen : fits within both axes (contain); never wider than effectiveWidth
|
|
||||||
* fit-original : natural image size, no constraint
|
|
||||||
*/
|
|
||||||
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||||
@@ -1034,7 +963,6 @@
|
|||||||
.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 {
|
.resume-banner {
|
||||||
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
|
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
@@ -1053,6 +981,14 @@
|
|||||||
transition: color var(--t-fast), background var(--t-fast);
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
}
|
}
|
||||||
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
.resume-jump {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--accent-fg); background: var(--accent-muted);
|
||||||
|
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 8px; cursor: pointer;
|
||||||
|
transition: filter var(--t-fast);
|
||||||
|
}
|
||||||
|
.resume-jump:hover { filter: brightness(1.15); }
|
||||||
|
|
||||||
@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>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
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, clearBookmarks } from "../../store/state.svelte";
|
||||||
import { cache } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||||
@@ -76,13 +76,178 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
let defaultDownloadsPath = $state("");
|
||||||
|
invoke<string>("get_default_downloads_path").then(p => { defaultDownloadsPath = p; });
|
||||||
|
|
||||||
|
// 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);
|
const pathData = await gql<{ settings: { downloadsPath: string; localSourcePath: string } }>(GET_DOWNLOADS_PATH);
|
||||||
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath });
|
const dl = pathData.settings.downloadsPath ?? "";
|
||||||
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
|
const loc = pathData.settings.localSourcePath ?? "";
|
||||||
finally { storageLoading = false; }
|
|
||||||
|
downloadsPathInput = dl;
|
||||||
|
localSourcePathInput = loc;
|
||||||
|
confirmedDownloadsPath = dl;
|
||||||
|
confirmedLocalSourcePath = loc;
|
||||||
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
|
||||||
|
|
||||||
|
// 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. */
|
||||||
|
async function validatePath(path: string): Promise<string | null> {
|
||||||
|
if (!path.trim()) return null; // empty = use default, always valid
|
||||||
|
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. */
|
||||||
|
async function createDirectory(path: string): Promise<void> {
|
||||||
|
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)
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// If downloads path changed and old path had content, offer migration
|
||||||
|
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,7 +825,7 @@
|
|||||||
<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 ?? 0) * 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
|
||||||
@@ -678,11 +843,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 +1028,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 & Zoom</p>
|
<p class="section-title">Fit & 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 +1057,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 +1090,36 @@
|
|||||||
{/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">Bookmarks</span><span class="toggle-desc">One per manga — acts like a physical bookmark</span></div>
|
||||||
|
<button role="switch" aria-checked={store.settings.bookmarksEnabled ?? true} aria-label="Enable bookmarks" class="toggle" class:on={store.settings.bookmarksEnabled ?? true} onclick={() => {
|
||||||
|
const next = !(store.settings.bookmarksEnabled ?? true);
|
||||||
|
updateSettings({ bookmarksEnabled: next });
|
||||||
|
if (!next) clearBookmarks();
|
||||||
|
}}><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 +1133,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 +1159,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 +1178,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 +1195,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 +1218,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 +1271,193 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if tab === "storage"}
|
{:else if tab === "storage"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|
||||||
|
<!-- ── Migration banner ──────────────────────────────────────── -->
|
||||||
|
{#if migrateFrom}
|
||||||
|
<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>
|
<p class="section-title">Disk Usage</p>
|
||||||
{#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 multiStorageInfos.length > 0}
|
||||||
{@const freeBytes = storageInfo.free_bytes}
|
{#each multiStorageInfos as info}
|
||||||
{@const limitGb = store.settings.storageLimitGb ?? null}
|
{@const limitGb = store.settings.storageLimitGb ?? null}
|
||||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||||
{@const available = mangaBytes + freeBytes}
|
{@const available = info.manga_bytes + info.free_bytes}
|
||||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||||
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0}
|
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
|
||||||
<div class="storage-bar-wrap">
|
<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">
|
||||||
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div>
|
<div class="storage-bar-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="storage-bar-labels">
|
<div class="storage-bar-labels">
|
||||||
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span>
|
<span class="storage-path-note" style="margin:0">{info.path}</span>
|
||||||
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span>
|
<span class="storage-bar-free">{fmtBytes(info.free_bytes)} free</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="storage-legend">
|
{/each}
|
||||||
<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>
|
{:else}
|
||||||
<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>
|
<p class="storage-loading">No download path configured.</p>
|
||||||
<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">
|
<div class="path-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
|
<input
|
||||||
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
|
class="text-input path-input"
|
||||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
class:path-input-error={!!pathsFieldError.dl}
|
||||||
|
bind:value={downloadsPathInput}
|
||||||
|
placeholder={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>
|
||||||
|
<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 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>
|
</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}
|
||||||
|
<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 +1526,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 +1558,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 +1835,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 +1869,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,6 +1976,7 @@
|
|||||||
{: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-scroll">
|
||||||
<div class="release-list">
|
<div class="release-list">
|
||||||
{#each releases as release}
|
{#each releases as release}
|
||||||
{@const isCurrent = isCurrentVersion(release.tag_name)}
|
{@const isCurrent = isCurrentVersion(release.tag_name)}
|
||||||
@@ -1749,6 +2026,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -1883,6 +2161,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 +2178,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); }
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+21
-21
@@ -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;
|
||||||
|
bookmarksEnabled: 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,
|
||||||
|
bookmarksEnabled: 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);
|
||||||
}
|
}
|
||||||
@@ -564,10 +569,12 @@ class Store {
|
|||||||
* per chapter is kept — adding a second one replaces the first.
|
* per chapter is kept — adding a second one replaces the first.
|
||||||
*/
|
*/
|
||||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||||
|
if (!(this.settings.bookmarksEnabled ?? true)) return;
|
||||||
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 +582,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 +726,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 +752,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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user