mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-14 01:39:56 -05:00
Compare commits
27 Commits
0.9.2
...
cbf8a7fe13
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e |
@@ -22,7 +22,7 @@ source=(
|
|||||||
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
)
|
)
|
||||||
sha256sums=(
|
sha256sums=(
|
||||||
'4d0fbed929d5660ddcb591ff33f808910e13df1e8e7bfc8df83f367fd7bcd881'
|
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
||||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ package() {
|
|||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
|
|||||||
@@ -35,8 +35,5 @@ In-Progress:
|
|||||||
|
|
||||||
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
|
|
||||||
- Add Disable Auto-Completed Feature to Library
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
- Cap ReaderSettings Zoom (100)
|
|
||||||
- Fix SeriesDetail Chapter Amount (Link to Scanlator Filtering)
|
|
||||||
|
|
||||||
Notes from last time:
|
Notes from last time:
|
||||||
|
|||||||
@@ -53,8 +53,6 @@
|
|||||||
gsettings-desktop-schemas
|
gsettings-desktop-schemas
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── source filters ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
frontendSrc = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter =
|
filter =
|
||||||
@@ -80,8 +78,6 @@
|
|||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── packages ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||||
|
|
||||||
frontend = pkgs.callPackage ./nix/frontend.nix {
|
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||||
@@ -94,8 +90,6 @@
|
|||||||
appIcon = ./src/assets/moku-icon.svg;
|
appIcon = ./src/assets/moku-icon.svg;
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── dev/release scripts ─────────────────────────────────────────
|
|
||||||
|
|
||||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||||
|
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ modules:
|
|||||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -130,7 +130,7 @@ modules:
|
|||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
# Append keys if absent
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ modules:
|
|||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/moku-project/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.9.2
|
tag: v0.9.2
|
||||||
commit: e33464b05baddc7c4ad3815f3f126f791e8c58cc
|
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086
|
sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ stdenv.mkDerivation {
|
|||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-t6Gj84hCE3CuDAJfbdXi0FuqgPCqlkMmAzETcKL4e3U=";
|
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
|
|||||||
+8
-8
@@ -10,23 +10,23 @@
|
|||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-http": "^2.5.8",
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tauri-apps/plugin-store": "~2.4.2",
|
"@tauri-apps/plugin-store": "~2.4.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^4.0.1",
|
"svelte-spa-router": "^5.1.0",
|
||||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
"tauri-plugin-drpc": "^1.0.3"
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.11.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^3.0.0",
|
"svelte-check": "^4.4.7",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^5.0.0"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+467
-890
File diff suppressed because it is too large
Load Diff
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
|
|||||||
cat > "$DATA_DIR/server.conf" << 'EOF'
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -79,13 +79,13 @@ fi
|
|||||||
|
|
||||||
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent (e.g. user-managed conf missing them)
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(),
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
backup_dir(&app).to_string_lossy().into_owned()
|
let dir = backup_dir(&app);
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -47,12 +47,22 @@ mod windows_hello {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn authenticate(reason: &str) -> Result<(), String> {
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
nudge_focus(5, 250);
|
let reason = reason.to_owned();
|
||||||
let result = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason))
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
.and_then(|op| {
|
|
||||||
nudge_focus(5, 250);
|
std::thread::spawn(move || {
|
||||||
op.get()
|
nudge_focus(5, 250);
|
||||||
})
|
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||||
|
.and_then(|op| {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
op.get()
|
||||||
|
});
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?
|
||||||
.map_err(|e| format!("internalError:{e:?}"))?;
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -77,9 +87,9 @@ mod windows_hello {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn windows_hello_authenticate(reason: String) -> Result<(), String> {
|
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
return windows_hello::authenticate(&reason);
|
return windows_hello::authenticate(&_reason);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
Err("notSupported".into())
|
Err("notSupported".into())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use crate::server::resolve::strip_unc;
|
use crate::server::resolve::strip_unc;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
|
||||||
.plugin(tauri_plugin_discord_rpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
@@ -39,6 +38,7 @@ pub fn run() {
|
|||||||
commands::backup::import_app_data,
|
commands::backup::import_app_data,
|
||||||
commands::backup::auto_backup_app_data,
|
commands::backup::auto_backup_app_data,
|
||||||
commands::backup::get_auto_backup_dir,
|
commands::backup::get_auto_backup_dir,
|
||||||
|
commands::backup::read_store_files,
|
||||||
commands::updater::list_releases,
|
commands::updater::list_releases,
|
||||||
commands::updater::download_and_install_update,
|
commands::updater::download_and_install_update,
|
||||||
commands::biometric::windows_hello_authenticate,
|
commands::biometric::windows_hello_authenticate,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::server::do_log;
|
use crate::server::do_log;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use walkdir::WalkDir;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
|
|||||||
+68
-21
@@ -1,10 +1,24 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { fetchAuthenticated, AuthRequiredError } from "../core/auth";
|
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
|
||||||
import { boot } from "@store/boot.svelte";
|
import { boot } from "@store/boot.svelte";
|
||||||
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
type ReauthResolver = () => void;
|
||||||
|
let _reauthQueue: ReauthResolver[] = [];
|
||||||
|
|
||||||
|
export function notifyReauthSuccess() {
|
||||||
|
const queue = _reauthQueue;
|
||||||
|
_reauthQueue = [];
|
||||||
|
queue.forEach(resolve => resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReauth(): Promise<void> {
|
||||||
|
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerUrl(): string {
|
||||||
const url = store.settings.serverUrl;
|
const url = store.settings.serverUrl;
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
}
|
}
|
||||||
@@ -15,6 +29,14 @@ export function plainThumbUrl(path: string): string {
|
|||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveImageUrl(path: string): Promise<string> {
|
||||||
|
if (!path) return "";
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "NONE") return url;
|
||||||
|
return getBlobUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
export const thumbUrl = plainThumbUrl;
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
@@ -58,29 +80,54 @@ async function fetchWithRetry(
|
|||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchImage(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ src: string; revoke: () => void }> {
|
||||||
|
if (!path) return { src: "", revoke: () => {} };
|
||||||
|
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "NONE") return { src: url, revoke: () => {} };
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
||||||
|
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
return { src, revoke: () => URL.revokeObjectURL(src) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(
|
const attempt = async (): Promise<T> => {
|
||||||
`${getServerUrl()}/api/graphql`,
|
const res = await fetchWithRetry(
|
||||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
`${getServerUrl()}/api/graphql`,
|
||||||
signal,
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
);
|
signal,
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
);
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
const json: GQLResponse<T> = await res.json();
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
const json: GQLResponse<T> = await res.json();
|
||||||
if (json.errors?.length) {
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
if (json.errors?.length) {
|
||||||
if (isAuthError && !boot.skipped) {
|
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||||
boot.sessionExpired = true;
|
if (isAuthError && !boot.skipped) {
|
||||||
boot.loginRequired = true;
|
boot.sessionExpired = true;
|
||||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
boot.loginRequired = true;
|
||||||
throw new AuthRequiredError(json.errors[0].message);
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
await waitForReauth();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
throw new Error(json.errors[0].message);
|
||||||
}
|
}
|
||||||
throw new Error(json.errors[0].message);
|
return json.data;
|
||||||
}
|
};
|
||||||
return json.data;
|
|
||||||
|
return attempt();
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,48 @@ export const UPDATE_SOURCE_PREFERENCE = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SET_SOURCE_METAS = `
|
||||||
|
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
||||||
|
setSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_METAS = `
|
||||||
|
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
||||||
|
deleteSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_SOURCE_METADATA = `
|
||||||
|
mutation UpdateSourceMetadata(
|
||||||
|
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPreUpdateDeletions: Boolean!
|
||||||
|
$updateInput: SetSourceMetasInput!
|
||||||
|
$hasUpdates: Boolean!
|
||||||
|
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPostUpdateDeletions: Boolean!
|
||||||
|
$migrateInput: SetSourceMetasInput!
|
||||||
|
$isMigration: Boolean!
|
||||||
|
) {
|
||||||
|
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const SET_SOURCE_META = `
|
export const SET_SOURCE_META = `
|
||||||
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||||
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||||
|
|||||||
@@ -23,6 +23,77 @@ export const GET_SOURCES = `
|
|||||||
nodes {
|
nodes {
|
||||||
id name lang displayName iconUrl isNsfw
|
id name lang displayName iconUrl isNsfw
|
||||||
isConfigurable supportsLatest baseUrl
|
isConfigurable supportsLatest baseUrl
|
||||||
|
extension { pkgName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCE_SETTINGS = `
|
||||||
|
query GetSourceSettings($id: LongString!) {
|
||||||
|
source(id: $id) {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
preferences {
|
||||||
|
... on CheckBoxPreference {
|
||||||
|
type: __typename
|
||||||
|
CheckBoxTitle: title
|
||||||
|
CheckBoxSummary: summary
|
||||||
|
CheckBoxDefault: default
|
||||||
|
CheckBoxCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on SwitchPreference {
|
||||||
|
type: __typename
|
||||||
|
SwitchPreferenceTitle: title
|
||||||
|
SwitchPreferenceSummary: summary
|
||||||
|
SwitchPreferenceDefault: default
|
||||||
|
SwitchPreferenceCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on ListPreference {
|
||||||
|
type: __typename
|
||||||
|
ListPreferenceTitle: title
|
||||||
|
ListPreferenceSummary: summary
|
||||||
|
ListPreferenceDefault: default
|
||||||
|
ListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on EditTextPreference {
|
||||||
|
type: __typename
|
||||||
|
EditTextPreferenceTitle: title
|
||||||
|
EditTextPreferenceSummary: summary
|
||||||
|
EditTextPreferenceDefault: default
|
||||||
|
EditTextPreferenceCurrentValue: currentValue
|
||||||
|
dialogTitle
|
||||||
|
dialogMessage
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on MultiSelectListPreference {
|
||||||
|
type: __typename
|
||||||
|
MultiSelectListPreferenceTitle: title
|
||||||
|
MultiSelectListPreferenceSummary: summary
|
||||||
|
MultiSelectListPreferenceDefault: default
|
||||||
|
MultiSelectListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MIGRATABLE_SOURCES = `
|
||||||
|
query GetMigratableSources {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
sourceId
|
||||||
|
source {
|
||||||
|
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -9,12 +9,13 @@ export class AuthRequiredError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _accessToken: string | null = null;
|
const TOKEN_KEY = "moku_access_token";
|
||||||
|
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
|
||||||
export const uiAuth = {
|
export const uiAuth = {
|
||||||
getToken: () => _accessToken,
|
getToken: () => _accessToken,
|
||||||
setToken: (t: string) => { _accessToken = t; },
|
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
||||||
clearToken: () => { _accessToken = null; },
|
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authSession = {
|
export const authSession = {
|
||||||
|
|||||||
Vendored
+29
-5
@@ -1,5 +1,6 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
|
import { uiAuth } from "@core/auth";
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
@@ -17,9 +18,17 @@ interface QueueEntry {
|
|||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
function getAuthHeaders(): Record<string, string> {
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
if (mode === "UI_LOGIN") {
|
||||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
const token = uiAuth.getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(url: string): Promise<string> {
|
async function doFetch(url: string): Promise<string> {
|
||||||
@@ -47,7 +56,7 @@ function drain() {
|
|||||||
active++;
|
active++;
|
||||||
doFetch(entry.url)
|
doFetch(entry.url)
|
||||||
.then(entry.resolve, entry.reject)
|
.then(entry.resolve, entry.reject)
|
||||||
.finally(() => { inflight.delete(entry.url); active--; drain(); });
|
.finally(() => { active--; drain(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +67,12 @@ function scheduleDrain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(url: string, priority: number): Promise<string> {
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); });
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
|
insertSorted({ url, priority, resolve, reject });
|
||||||
|
}).catch(err => {
|
||||||
|
inflight.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
inflight.set(url, promise);
|
inflight.set(url, promise);
|
||||||
scheduleDrain();
|
scheduleDrain();
|
||||||
return promise;
|
return promise;
|
||||||
@@ -98,7 +112,17 @@ export function deprioritizeQueue(): void {
|
|||||||
queue.sort((a, b) => b.priority - a.priority);
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelQueuedFetches(): void {
|
||||||
|
const dropped = queue.splice(0);
|
||||||
|
for (const entry of dropped) {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
entry.reject(new DOMException("Cancelled", "AbortError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
}
|
}
|
||||||
Vendored
+23
-15
@@ -1,18 +1,23 @@
|
|||||||
import { gql, plainThumbUrl } from "@api/client";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import { dedupeRequest } from "@core/async/batchRequests";
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
|
|
||||||
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 resolvedUrlCache = new Map<string, Promise<string>>();
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
const preloadedUrls = new Set<string>();
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
if (!useBlob) return Promise.resolve(url);
|
if (!useBlob) return Promise.resolve(url);
|
||||||
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
const cached = resolvedUrlCache.get(url);
|
||||||
return resolvedUrlCache.get(url)!;
|
if (cached) return cached;
|
||||||
|
const p = getBlobUrl(url, priority).catch(err => {
|
||||||
|
resolvedUrlCache.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
resolvedUrlCache.set(url, p);
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPages(
|
export function fetchPages(
|
||||||
@@ -29,11 +34,8 @@ export function fetchPages(
|
|||||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||||
.then(d => {
|
.then(d => {
|
||||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
|
||||||
if (useBlob) {
|
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
|
||||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
|
||||||
}
|
|
||||||
pageCache.set(chapterId, urls);
|
pageCache.set(chapterId, urls);
|
||||||
return urls;
|
return urls;
|
||||||
})
|
})
|
||||||
@@ -60,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function preloadImage(url: string, useBlob: boolean): void {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (preloadedUrls.has(url)) return;
|
if (useBlob) {
|
||||||
preloadedUrls.add(url);
|
preloadBlobUrls([url], 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
@@ -73,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
|
|||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
resolvedUrlCache.clear();
|
resolvedUrlCache.clear();
|
||||||
preloadedUrls.clear();
|
|
||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+13
@@ -159,3 +159,16 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||||
|
cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||||
|
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||||
|
revokeBlobUrl(thumbnailUrl);
|
||||||
|
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-8
@@ -31,13 +31,13 @@ export function formatReadTime(m: number): string {
|
|||||||
|
|
||||||
const STRICT_TAGS: string[] = [
|
const STRICT_TAGS: string[] = [
|
||||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
"18+", "smut", "lemon", "explicit", "sexual violence",
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
"gore", "guro", "graphic violence", "torture", "body horror",
|
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODERATE_TAGS: string[] = [
|
const MODERATE_TAGS: string[] = [
|
||||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
"18+", "smut", "lemon", "explicit", "sexual violence",
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
];
|
];
|
||||||
|
|
||||||
type ContentFilterSettings = Pick<
|
type ContentFilterSettings = Pick<
|
||||||
@@ -53,7 +53,16 @@ function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
|||||||
|
|
||||||
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||||
if (!blockedTags.length) return false;
|
if (!blockedTags.length) return false;
|
||||||
return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag)));
|
return genre.some(g => {
|
||||||
|
const norm = g.toLowerCase().trim();
|
||||||
|
return blockedTags.some(tag => {
|
||||||
|
const idx = norm.indexOf(tag);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||||
|
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||||
|
return before && after;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldHideNsfw(
|
export function shouldHideNsfw(
|
||||||
@@ -69,10 +78,10 @@ export function shouldHideNsfw(
|
|||||||
if (srcId && blocked.includes(srcId)) return true;
|
if (srcId && blocked.includes(srcId)) return true;
|
||||||
|
|
||||||
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||||
const blockedTags = blockedTagsForSettings(settings);
|
|
||||||
|
|
||||||
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true;
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
|
|
||||||
|
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldHideSource(
|
export function shouldHideSource(
|
||||||
@@ -83,10 +92,10 @@ export function shouldHideSource(
|
|||||||
|
|
||||||
if (settings.sourceOverridesEnabled) {
|
if (settings.sourceOverridesEnabled) {
|
||||||
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||||
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict";
|
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.isNsfw && settings.contentLevel === "strict";
|
return source.isNsfw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dedupeSourcesByLang(
|
export function dedupeSourcesByLang(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
let filtered = allSources;
|
let filtered = allSources;
|
||||||
if (kw_selectedLangs.size > 0)
|
if (kw_selectedLangs.size > 0)
|
||||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
if (!store.settings.showNsfw)
|
if (store.settings.contentLevel !== "unrestricted")
|
||||||
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
import { shouldHideNsfw } from "@core/util";
|
import { shouldHideNsfw } from "@core/util";
|
||||||
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
|
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||||
import {
|
import {
|
||||||
toCachedManga,
|
toCachedManga,
|
||||||
type CachedManga,
|
type CachedManga,
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
|
query={store.searchQuery}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||||
onPreview={setPreviewManga}
|
onPreview={setPreviewManga}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -211,8 +211,8 @@
|
|||||||
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||||
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
||||||
} from "@api/mutations";
|
} from "@api/mutations";
|
||||||
import { addToast, setActiveDownloads } from "@store/state.svelte";
|
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
|
||||||
import { boot } from "@store/boot.svelte";
|
import { boot } from "@store/boot.svelte";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||||
import {
|
import {
|
||||||
@@ -26,8 +26,8 @@ class DownloadStore {
|
|||||||
pagesPerSec: number | null = $state(null);
|
pagesPerSec: number | null = $state(null);
|
||||||
eta: number | null = $state(null);
|
eta: number | null = $state(null);
|
||||||
|
|
||||||
toastsEnabled = $state(true);
|
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||||
autoRetryEnabled = $state(false);
|
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||||
|
|
||||||
private lastSample: SpeedSample | null = null;
|
private lastSample: SpeedSample | null = null;
|
||||||
private prevQueue: DownloadQueueItem[] = [];
|
private prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -39,18 +39,19 @@ class DownloadStore {
|
|||||||
get hasErrored() { return this.erroredIds.size > 0; }
|
get hasErrored() { return this.erroredIds.size > 0; }
|
||||||
|
|
||||||
toggleToasts() {
|
toggleToasts() {
|
||||||
this.toastsEnabled = !this.toastsEnabled;
|
const next = !this.toastsEnabled;
|
||||||
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
updateSettings({ downloadToastsEnabled: next });
|
||||||
|
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAutoRetry() {
|
toggleAutoRetry() {
|
||||||
if (this.autoRetryEnabled) {
|
if (this.autoRetryEnabled) {
|
||||||
this.autoRetryHnd?.stop();
|
this.autoRetryHnd?.stop();
|
||||||
this.autoRetryHnd = null;
|
this.autoRetryHnd = null;
|
||||||
this.autoRetryEnabled = false;
|
updateSettings({ downloadAutoRetry: false });
|
||||||
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
||||||
} else {
|
} else {
|
||||||
this.autoRetryEnabled = true;
|
updateSettings({ downloadAutoRetry: true });
|
||||||
this.autoRetryHnd = startAutoRetry(
|
this.autoRetryHnd = startAutoRetry(
|
||||||
() => this.queue,
|
() => this.queue,
|
||||||
() => this.isRunning,
|
() => this.isRunning,
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte";
|
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { Extension } from "@types/index";
|
import type { Extension } from "@types/index";
|
||||||
|
|
||||||
|
type SourceEntry = { id: string; displayName: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
base: string;
|
base: string;
|
||||||
primary: Extension;
|
primary: Extension;
|
||||||
variants: Extension[];
|
variants: Extension[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
working: Set<string>;
|
working: Set<string>;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
onToggle: (base: string) => void;
|
sources: SourceEntry[];
|
||||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
libraryCount: number;
|
||||||
|
onToggle: (base: string) => void;
|
||||||
|
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||||
|
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { base, primary, variants, expanded, working, anims, onToggle, onMutate }: Props = $props();
|
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
|
||||||
|
|
||||||
|
const clickable = $derived(primary.isInstalled);
|
||||||
|
|
||||||
const hasVariants = $derived(variants.length > 0);
|
const hasVariants = $derived(variants.length > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="row">
|
<svelte:element
|
||||||
|
this={clickable ? "button" : "div"}
|
||||||
|
class="row"
|
||||||
|
class:row-clickable={clickable}
|
||||||
|
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
|
||||||
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
src={primary.iconUrl}
|
src={primary.iconUrl}
|
||||||
alt={primary.name}
|
alt={primary.name}
|
||||||
@@ -31,6 +43,13 @@
|
|||||||
<span class="name">{base}</span>
|
<span class="name">{base}</span>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
||||||
|
{#if primary.isInstalled}
|
||||||
|
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
|
||||||
|
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
|
||||||
|
{libraryCount > 0 ? libraryCount : 0}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
v{primary.versionName}
|
v{primary.versionName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,22 +58,24 @@
|
|||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
{:else if primary.hasUpdate}
|
{:else if primary.hasUpdate}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "update")}>Update</button>
|
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
|
||||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if primary.isInstalled}
|
{:else if primary.isInstalled}
|
||||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
<div class="row-actions">
|
||||||
|
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasVariants}
|
{#if hasVariants}
|
||||||
<button class="expand-btn" onclick={() => onToggle(base)} title="{variants.length + 1} languages">
|
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
|
||||||
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
<span class="expand-count">{variants.length + 1}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</svelte:element>
|
||||||
|
|
||||||
{#if expanded && hasVariants}
|
{#if expanded && hasVariants}
|
||||||
<div class="variants" class:variants-anim={anims}>
|
<div class="variants" class:variants-anim={anims}>
|
||||||
@@ -68,11 +89,11 @@
|
|||||||
{#if working.has(v.pkgName)}
|
{#if working.has(v.pkgName)}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
{:else if v.hasUpdate}
|
{:else if v.hasUpdate}
|
||||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
|
<button class="action-btn" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
|
||||||
{:else if v.isInstalled}
|
{:else if v.isInstalled}
|
||||||
<button class="action-btn-dim" onclick={() => onMutate(v.pkgName, "uninstall")}>Remove</button>
|
<button class="action-btn-dim" onclick={() => onMutate(v.pkgName, "uninstall")}>Remove</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
|
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,15 +104,18 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.group { display: flex; flex-direction: column; }
|
.group { display: flex; flex-direction: column; }
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.row-clickable { cursor: pointer; }
|
||||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||||
|
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
|
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; 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); }
|
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; 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); }
|
||||||
.action-btn:hover { filter: brightness(1.1); }
|
.action-btn:hover { filter: brightness(1.1); }
|
||||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
@@ -106,5 +130,5 @@
|
|||||||
.variant-row:hover { background: var(--bg-raised); }
|
.variant-row:hover { background: var(--bg-raised); }
|
||||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
.variant-actions { flex-shrink: 0; }
|
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
@@ -3,14 +3,21 @@
|
|||||||
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { store, addToast } from "@store/state.svelte";
|
import { store, addToast } from "@store/state.svelte";
|
||||||
import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries";
|
import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
|
||||||
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
||||||
import type { Extension } from "@types/index";
|
import type { Extension } from "@types/index";
|
||||||
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
|
||||||
import ExtensionCard from "./ExtensionCard.svelte";
|
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||||
|
import ExtensionCard from "./ExtensionCard.svelte";
|
||||||
|
import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte";
|
||||||
|
import ExtensionLibraryPanel from "../panels/ExtensionLibraryPanel.svelte";
|
||||||
|
|
||||||
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
|
const cols = $derived(store.settings.libraryCols ?? 5);
|
||||||
|
const cropCovers = $derived(store.settings.cropCovers ?? true);
|
||||||
|
const statsAlways = $derived(store.settings.statsAlways ?? false);
|
||||||
|
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
|
||||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let tabIndicator = $state({ left: 0, width: 0 });
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
|
|
||||||
@@ -33,6 +40,15 @@
|
|||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
let panel = $state<Panel>(null);
|
let panel = $state<Panel>(null);
|
||||||
|
|
||||||
|
type SourceEntry = { id: string; displayName: string };
|
||||||
|
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
|
||||||
|
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
|
||||||
|
|
||||||
|
let settingsTarget = $state<SettingsTarget | null>(null);
|
||||||
|
let libraryTarget = $state<LibraryTarget | null>(null);
|
||||||
|
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
|
||||||
|
let libCountByPkg = $state<Record<string, number>>({});
|
||||||
|
|
||||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
let externalUrl = $state("");
|
let externalUrl = $state("");
|
||||||
@@ -47,8 +63,25 @@
|
|||||||
let savingRepos = $state(false);
|
let savingRepos = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error);
|
const [extData, srcData, libData] = await Promise.all([
|
||||||
if (d) extensions = d.extensions.nodes;
|
gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
|
||||||
|
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
|
||||||
|
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
|
||||||
|
]);
|
||||||
|
if (extData) extensions = extData.extensions.nodes;
|
||||||
|
if (srcData) {
|
||||||
|
const map: Record<string, SourceEntry[]> = {};
|
||||||
|
for (const s of srcData.sources.nodes) {
|
||||||
|
if (!s.isConfigurable || !s.extension?.pkgName) continue;
|
||||||
|
const pkg = s.extension.pkgName;
|
||||||
|
if (!map[pkg]) map[pkg] = [];
|
||||||
|
map[pkg].push({ id: s.id, displayName: s.displayName });
|
||||||
|
}
|
||||||
|
sourcesByPkg = map;
|
||||||
|
}
|
||||||
|
if (libData && srcData) {
|
||||||
|
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLocalManga() {
|
async function loadLocalManga() {
|
||||||
@@ -213,118 +246,143 @@
|
|||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
{#if libraryTarget}
|
||||||
<ExtensionFilters
|
<ExtensionLibraryPanel
|
||||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
pkgName={libraryTarget.pkgName}
|
||||||
{anims} {tabIndicator} {updatingAll}
|
extensionName={libraryTarget.extensionName}
|
||||||
bind:tabsEl
|
iconUrl={libraryTarget.iconUrl}
|
||||||
onFilter={setFilter}
|
{cols} {cropCovers} {statsAlways} {anims}
|
||||||
onSearch={(q) => search = q}
|
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
|
||||||
onLang={(l) => langFilter = l}
|
onBack={() => libraryTarget = null}
|
||||||
onPanel={openPanel}
|
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
|
||||||
onRefresh={fetchFromRepo}
|
|
||||||
onUpdateAll={updateAll}
|
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="root anim-fade-in">
|
||||||
|
<ExtensionFilters
|
||||||
|
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||||
|
{anims} {tabIndicator} {updatingAll}
|
||||||
|
bind:tabsEl
|
||||||
|
onFilter={setFilter}
|
||||||
|
onSearch={(q) => search = q}
|
||||||
|
onLang={(l) => langFilter = l}
|
||||||
|
onPanel={openPanel}
|
||||||
|
onRefresh={fetchFromRepo}
|
||||||
|
onUpdateAll={updateAll}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if panel === "apk"}
|
{#if panel === "apk"}
|
||||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
|
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ext-row">
|
|
||||||
<input
|
|
||||||
class="ext-input" class:error={installError}
|
|
||||||
placeholder="https://example.com/extension.apk"
|
|
||||||
bind:value={externalUrl} disabled={installing}
|
|
||||||
oninput={() => installError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
|
||||||
use:focusOnMount
|
|
||||||
/>
|
|
||||||
<button class="install-btn" class:success={installSuccess}
|
|
||||||
onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
|
||||||
{:else}Install{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if panel === "repos"}
|
|
||||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
|
|
||||||
</div>
|
|
||||||
{#if reposLoading}
|
|
||||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else}
|
|
||||||
{#if repos.length === 0}
|
|
||||||
<div class="repo-empty">No repos configured.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-list">
|
|
||||||
{#each repos as url}
|
|
||||||
<div class="repo-row">
|
|
||||||
<span class="repo-url">{url}</span>
|
|
||||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
|
||||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ext-row">
|
<div class="ext-row">
|
||||||
<input
|
<input
|
||||||
class="ext-input" class:error={repoError}
|
class="ext-input" class:error={installError}
|
||||||
placeholder="https://example.com/index.min.json"
|
placeholder="https://example.com/extension.apk"
|
||||||
bind:value={newRepoUrl} disabled={savingRepos}
|
bind:value={externalUrl} disabled={installing}
|
||||||
oninput={() => repoError = null}
|
oninput={() => installError = null}
|
||||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||||
|
use:focusOnMount
|
||||||
/>
|
/>
|
||||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
<button class="install-btn" class:success={installSuccess}
|
||||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||||
|
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
|
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||||
|
{:else}Install{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if panel === "repos"}
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||||
{:else}
|
<div class="panel-header">
|
||||||
<div class="list">
|
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
|
||||||
{#if showLocal}
|
|
||||||
<div class="local-row">
|
|
||||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
|
||||||
<div class="info">
|
|
||||||
<span class="name">Local Source</span>
|
|
||||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
|
||||||
</div>
|
|
||||||
<span class="local-badge">Built-in</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if reposLoading}
|
||||||
{#each groups as { base, primary, variants }}
|
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
<ExtensionCard
|
{:else}
|
||||||
{base} {primary} {variants} {working} {anims}
|
{#if repos.length === 0}
|
||||||
expanded={expanded.has(base)}
|
<div class="repo-empty">No repos configured.</div>
|
||||||
onToggle={toggleExpand}
|
{:else}
|
||||||
onMutate={mutate}
|
<div class="repo-list">
|
||||||
/>
|
{#each repos as url}
|
||||||
{/each}
|
<div class="repo-row">
|
||||||
{#if !showLocal && groups.length === 0}
|
<span class="repo-url">{url}</span>
|
||||||
<div class="empty" style="flex:1">No extensions found.</div>
|
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||||
{/if}
|
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ext-row">
|
||||||
|
<input
|
||||||
|
class="ext-input" class:error={repoError}
|
||||||
|
placeholder="https://example.com/index.min.json"
|
||||||
|
bind:value={newRepoUrl} disabled={savingRepos}
|
||||||
|
oninput={() => repoError = null}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||||
|
/>
|
||||||
|
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||||
|
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="list">
|
||||||
|
{#if showLocal}
|
||||||
|
<div class="local-row">
|
||||||
|
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="name">Local Source</span>
|
||||||
|
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
||||||
|
</div>
|
||||||
|
<span class="local-badge">Built-in</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each groups as { base, primary, variants }}
|
||||||
|
<ExtensionCard
|
||||||
|
{base} {primary} {variants} {working} {anims}
|
||||||
|
sources={sourcesByPkg[primary.pkgName] ?? []}
|
||||||
|
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
|
||||||
|
expanded={expanded.has(base)}
|
||||||
|
onToggle={toggleExpand}
|
||||||
|
onMutate={mutate}
|
||||||
|
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if !showLocal && groups.length === 0}
|
||||||
|
<div class="empty" style="flex:1">No extensions found.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if settingsTarget}
|
||||||
|
<ExtensionSettingsPanel
|
||||||
|
extensionName={settingsTarget.extensionName}
|
||||||
|
iconUrl={settingsTarget.iconUrl}
|
||||||
|
sources={settingsTarget.sources}
|
||||||
|
onClose={() => settingsTarget = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); background: var(--bg-raised); }
|
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
||||||
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||||
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface LibraryManga {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
unreadCount: number;
|
||||||
|
downloadCount: number;
|
||||||
|
source: { id: string; displayName: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceLibrary {
|
||||||
|
sourceId: string;
|
||||||
|
displayName: string;
|
||||||
|
manga: LibraryManga[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceNode = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
isConfigurable: boolean;
|
||||||
|
extension: { pkgName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function libraryByExtension(
|
||||||
|
libraryManga: LibraryManga[],
|
||||||
|
sources: SourceNode[],
|
||||||
|
pkgName: string,
|
||||||
|
): SourceLibrary[] {
|
||||||
|
const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName);
|
||||||
|
const sourceIds = new Set(pkgSources.map(s => s.id));
|
||||||
|
|
||||||
|
const bySource = new Map<string, LibraryManga[]>();
|
||||||
|
for (const src of pkgSources) bySource.set(src.id, []);
|
||||||
|
for (const m of libraryManga) {
|
||||||
|
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkgSources
|
||||||
|
.map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! }))
|
||||||
|
.filter(g => g.manga.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryCountByPkg(
|
||||||
|
libraryManga: LibraryManga[],
|
||||||
|
sources: SourceNode[],
|
||||||
|
): Record<string, number> {
|
||||||
|
const sourceIdToPkg = new Map<string, string>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
|
||||||
|
}
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const m of libraryManga) {
|
||||||
|
const pkg = sourceIdToPkg.get(m.source.id);
|
||||||
|
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, MagnifyingGlass, GearSix } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { setPreviewManga } from "@store/state.svelte";
|
||||||
|
import { GET_LIBRARY, GET_SOURCES } from "@api/queries";
|
||||||
|
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "../lib/extensionLibrary";
|
||||||
|
|
||||||
|
type SourceEntry = { id: string; displayName: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pkgName: string;
|
||||||
|
extensionName: string;
|
||||||
|
iconUrl: string;
|
||||||
|
cols: number;
|
||||||
|
cropCovers: boolean;
|
||||||
|
statsAlways: boolean;
|
||||||
|
anims: boolean;
|
||||||
|
sources: SourceEntry[];
|
||||||
|
onBack: () => void;
|
||||||
|
onSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||||
|
|
||||||
|
let groups: SourceLibrary[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let search = $state("");
|
||||||
|
|
||||||
|
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||||
|
const filtered = $derived(
|
||||||
|
search.trim()
|
||||||
|
? allManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: allManga
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const [libData, srcData] = await Promise.all([
|
||||||
|
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY),
|
||||||
|
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES),
|
||||||
|
]);
|
||||||
|
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<button class="back-btn" onclick={onBack}>
|
||||||
|
<ArrowLeft size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{#if iconUrl}
|
||||||
|
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
|
{/if}
|
||||||
|
<div class="title-block">
|
||||||
|
<span class="eyebrow">In Library</span>
|
||||||
|
<span class="title">{extensionName}</span>
|
||||||
|
</div>
|
||||||
|
{#if !loading}
|
||||||
|
<span class="count-badge">{allManga.length}</span>
|
||||||
|
{/if}
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
|
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
{#if sources.length > 0}
|
||||||
|
<button class="settings-btn" onclick={onSettings} title="Extension settings">
|
||||||
|
<GearSix size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{#if loading}
|
||||||
|
<div class="grid" style="--cols:{cols}">
|
||||||
|
{#each Array(12) as _}
|
||||||
|
<div class="card-skeleton">
|
||||||
|
<div class="cover-skeleton skeleton"></div>
|
||||||
|
<div class="title-skeleton skeleton"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid" style="--cols:{cols}">
|
||||||
|
{#each filtered as m (m.id)}
|
||||||
|
{@const isCompleted = !m.unreadCount && (m.downloadCount > 0)}
|
||||||
|
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
|
||||||
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
|
<Thumbnail
|
||||||
|
src={resolvedCover(m.id, m.thumbnailUrl)}
|
||||||
|
alt={m.title}
|
||||||
|
class="cover"
|
||||||
|
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||||
|
<div class="overlay-badges">
|
||||||
|
{#if isCompleted}
|
||||||
|
<span class="badge badge-done">✓ Done</span>
|
||||||
|
{:else if m.unreadCount}
|
||||||
|
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||||
|
{/if}
|
||||||
|
{#if m.downloadCount}
|
||||||
|
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="card-title">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.back-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.back-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.title-block { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.search-wrap { position: relative; display: flex; align-items: center; margin-left: auto; }
|
||||||
|
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
|
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||||
|
.search::placeholder { color: var(--text-faint); }
|
||||||
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.settings-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.settings-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||||
|
|
||||||
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
|
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||||
|
.card:hover .card-title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
||||||
|
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||||
|
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||||
|
|
||||||
|
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||||
|
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||||
|
.card-info-overlay.instant { transition: none; }
|
||||||
|
.card-info-overlay.always { opacity: 1; }
|
||||||
|
.card:hover .card-info-overlay { opacity: 1; }
|
||||||
|
|
||||||
|
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||||
|
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||||
|
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||||
|
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||||
|
|
||||||
|
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||||
|
.card.anims .card-title { transition: color var(--t-base); }
|
||||||
|
|
||||||
|
.card-skeleton { padding: 0; }
|
||||||
|
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
|
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { addToast } from "@store/state.svelte";
|
||||||
|
import { GET_SOURCE_SETTINGS } from "@api/queries";
|
||||||
|
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
|
||||||
|
|
||||||
|
interface Preference {
|
||||||
|
type: string;
|
||||||
|
key: string;
|
||||||
|
CheckBoxTitle?: string;
|
||||||
|
CheckBoxSummary?: string;
|
||||||
|
CheckBoxDefault?: boolean;
|
||||||
|
CheckBoxCurrentValue?: boolean;
|
||||||
|
SwitchPreferenceTitle?: string;
|
||||||
|
SwitchPreferenceSummary?: string;
|
||||||
|
SwitchPreferenceDefault?: boolean;
|
||||||
|
SwitchPreferenceCurrentValue?: boolean;
|
||||||
|
ListPreferenceTitle?: string;
|
||||||
|
ListPreferenceSummary?: string;
|
||||||
|
ListPreferenceDefault?: string;
|
||||||
|
ListPreferenceCurrentValue?: string;
|
||||||
|
entries?: string[];
|
||||||
|
entryValues?: string[];
|
||||||
|
EditTextPreferenceTitle?: string;
|
||||||
|
EditTextPreferenceSummary?: string;
|
||||||
|
EditTextPreferenceDefault?: string;
|
||||||
|
EditTextPreferenceCurrentValue?: string;
|
||||||
|
dialogTitle?: string;
|
||||||
|
dialogMessage?: string;
|
||||||
|
MultiSelectListPreferenceTitle?: string;
|
||||||
|
MultiSelectListPreferenceSummary?: string;
|
||||||
|
MultiSelectListPreferenceDefault?: string[];
|
||||||
|
MultiSelectListPreferenceCurrentValue?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceEntry = { id: string; displayName: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
extensionName: string;
|
||||||
|
iconUrl: string;
|
||||||
|
sources: SourceEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { extensionName, iconUrl, sources, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let phase = $state<"pick" | "settings">("pick");
|
||||||
|
let activeSource = $state<SourceEntry | null>(null);
|
||||||
|
let prefs = $state<Preference[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state<string | null>(null);
|
||||||
|
let editKey = $state<string | null>(null);
|
||||||
|
let editValue = $state("");
|
||||||
|
let listOpen = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (sources.length === 1) openSource(sources[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSource(src: SourceEntry) {
|
||||||
|
activeSource = src;
|
||||||
|
phase = "settings";
|
||||||
|
loading = true;
|
||||||
|
prefs = [];
|
||||||
|
editKey = null;
|
||||||
|
listOpen = null;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||||
|
GET_SOURCE_SETTINGS,
|
||||||
|
{ id: String(src.id) },
|
||||||
|
);
|
||||||
|
prefs = d.source.preferences ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToPicker() {
|
||||||
|
phase = "pick";
|
||||||
|
activeSource = null;
|
||||||
|
prefs = [];
|
||||||
|
editKey = null;
|
||||||
|
listOpen = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(position: number, changeType: string, value: unknown) {
|
||||||
|
if (!activeSource) return;
|
||||||
|
const pref = prefs[position];
|
||||||
|
saving = pref.key;
|
||||||
|
try {
|
||||||
|
await gql(UPDATE_SOURCE_PREFERENCE, {
|
||||||
|
source: String(activeSource.id),
|
||||||
|
change: { position, [changeType]: value },
|
||||||
|
});
|
||||||
|
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||||
|
GET_SOURCE_SETTINGS,
|
||||||
|
{ id: String(activeSource.id) },
|
||||||
|
);
|
||||||
|
prefs = d.source.preferences ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||||
|
} finally {
|
||||||
|
saving = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(p: Preference) {
|
||||||
|
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
|
||||||
|
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
|
||||||
|
}
|
||||||
|
function getSummary(p: Preference) {
|
||||||
|
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
|
||||||
|
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
|
||||||
|
}
|
||||||
|
function getBoolValue(p: Preference) {
|
||||||
|
if (p.type === "CheckBoxPreference")
|
||||||
|
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
|
||||||
|
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
|
||||||
|
}
|
||||||
|
function getListValue(p: Preference) {
|
||||||
|
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
|
||||||
|
}
|
||||||
|
function getListLabel(p: Preference, val: string) {
|
||||||
|
const idx = p.entryValues?.indexOf(val) ?? -1;
|
||||||
|
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
|
||||||
|
}
|
||||||
|
function getMultiValue(p: Preference): string[] {
|
||||||
|
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
|
||||||
|
}
|
||||||
|
function toggleMulti(position: number, p: Preference, val: string) {
|
||||||
|
const current = getMultiValue(p);
|
||||||
|
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
|
||||||
|
save(position, "multiSelectState", next);
|
||||||
|
}
|
||||||
|
function submitEdit(position: number) {
|
||||||
|
save(position, "editTextState", editValue);
|
||||||
|
editKey = null;
|
||||||
|
}
|
||||||
|
function openEdit(p: Preference) {
|
||||||
|
editKey = p.key;
|
||||||
|
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function langTag(displayName: string) {
|
||||||
|
const m = displayName.match(/\(([^)]+)\)$/);
|
||||||
|
return m ? m[1].toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (editKey) { editKey = null; return; }
|
||||||
|
if (listOpen) { listOpen = null; return; }
|
||||||
|
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
|
||||||
|
<div class="modal">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title-wrap">
|
||||||
|
{#if phase === "settings" && sources.length > 1}
|
||||||
|
<button class="icon-btn" onclick={backToPicker} title="Back">
|
||||||
|
<CaretLeft size={13} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if iconUrl}
|
||||||
|
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
|
{/if}
|
||||||
|
<div class="modal-titles">
|
||||||
|
<span class="modal-eyebrow">Extension Settings</span>
|
||||||
|
<span class="modal-title">
|
||||||
|
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" onclick={onClose}>
|
||||||
|
<X size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
{#if phase === "pick"}
|
||||||
|
<div class="source-list">
|
||||||
|
{#each sources as src}
|
||||||
|
{@const tag = langTag(src.displayName)}
|
||||||
|
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
|
||||||
|
<button class="source-row" onclick={() => openSource(src)}>
|
||||||
|
<span class="source-name">{baseName}</span>
|
||||||
|
{#if tag}<span class="lang-badge">{tag}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#if loading}
|
||||||
|
<div class="center-state">
|
||||||
|
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
</div>
|
||||||
|
{:else if prefs.length === 0}
|
||||||
|
<div class="center-state empty-state">No configurable settings.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pref-list">
|
||||||
|
{#each prefs as pref, i}
|
||||||
|
{@const title = getTitle(pref)}
|
||||||
|
{@const summary = getSummary(pref)}
|
||||||
|
{@const isSaving = saving === pref.key}
|
||||||
|
|
||||||
|
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
|
||||||
|
{@const checked = getBoolValue(pref)}
|
||||||
|
<div class="pref-row">
|
||||||
|
<div class="pref-text">
|
||||||
|
<span class="pref-title">{title}</span>
|
||||||
|
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle" class:toggle-on={checked}
|
||||||
|
disabled={isSaving}
|
||||||
|
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
|
||||||
|
>
|
||||||
|
{#if isSaving}
|
||||||
|
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if pref.type === "ListPreference"}
|
||||||
|
{@const current = getListValue(pref)}
|
||||||
|
<div class="pref-row pref-row-col">
|
||||||
|
<div class="pref-text">
|
||||||
|
<span class="pref-title">{title}</span>
|
||||||
|
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<button
|
||||||
|
class="select-btn" class:select-open={listOpen === pref.key}
|
||||||
|
disabled={isSaving}
|
||||||
|
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
|
||||||
|
>
|
||||||
|
<span class="select-val">{getListLabel(pref, current)}</span>
|
||||||
|
{#if isSaving}
|
||||||
|
<CircleNotch size={11} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<CaretUpDown size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if listOpen === pref.key}
|
||||||
|
<div class="dropdown">
|
||||||
|
{#each (pref.entries ?? []) as entry, j}
|
||||||
|
{@const val = pref.entryValues?.[j] ?? entry}
|
||||||
|
<button
|
||||||
|
class="dropdown-item" class:dropdown-item-active={val === current}
|
||||||
|
onclick={() => { save(i, "listState", val); listOpen = null; }}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
{#if val === current}<Check size={11} weight="bold" />{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if pref.type === "EditTextPreference"}
|
||||||
|
{#if editKey === pref.key}
|
||||||
|
<div class="pref-row pref-row-col edit-active">
|
||||||
|
<div class="pref-text">
|
||||||
|
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
|
||||||
|
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="edit-row">
|
||||||
|
<input
|
||||||
|
class="edit-input"
|
||||||
|
bind:value={editValue}
|
||||||
|
disabled={isSaving}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
|
||||||
|
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
|
||||||
|
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
|
||||||
|
<div class="pref-text">
|
||||||
|
<span class="pref-title">{title}</span>
|
||||||
|
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="pref-value-hint">
|
||||||
|
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if pref.type === "MultiSelectListPreference"}
|
||||||
|
{@const selected = getMultiValue(pref)}
|
||||||
|
<div class="pref-row pref-row-col">
|
||||||
|
<div class="pref-text">
|
||||||
|
<span class="pref-title">{title}</span>
|
||||||
|
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="multi-list">
|
||||||
|
{#each (pref.entries ?? []) as entry, j}
|
||||||
|
{@const val = pref.entryValues?.[j] ?? entry}
|
||||||
|
{@const on = selected.includes(val)}
|
||||||
|
<button
|
||||||
|
class="multi-item" class:multi-item-on={on}
|
||||||
|
disabled={isSaving}
|
||||||
|
onclick={() => toggleMulti(i, pref, val)}
|
||||||
|
>
|
||||||
|
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
|
||||||
|
{entry}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
animation: fadeIn 0.15s ease both;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.modal-eyebrow {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.modal-body { overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||||
|
.source-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px var(--sp-4);
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
.source-row:hover { background: var(--bg-raised); }
|
||||||
|
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.lang-badge {
|
||||||
|
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-faint); background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1px 6px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||||
|
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||||
|
.pref-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: 10px var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
.pref-row:last-child { border-bottom: none; }
|
||||||
|
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
||||||
|
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
|
||||||
|
.pref-row-btn:hover { background: var(--bg-raised); }
|
||||||
|
.edit-active { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.pref-summary {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.pref-value-hint {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||||
|
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 32px; height: 18px; border-radius: 9px;
|
||||||
|
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||||
|
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute; left: 2px; width: 12px; height: 12px;
|
||||||
|
border-radius: 50%; background: var(--text-faint);
|
||||||
|
transition: left var(--t-base), background var(--t-base); pointer-events: none;
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
|
||||||
|
.toggle:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.select-wrap { position: relative; }
|
||||||
|
.select-btn {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 6px var(--sp-3);
|
||||||
|
background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||||
|
.select-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.select-open { border-color: var(--border-focus); }
|
||||||
|
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md); overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-lg); z-index: 10;
|
||||||
|
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
}
|
||||||
|
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.dropdown-item:hover { background: var(--bg-raised); }
|
||||||
|
.dropdown-item-active { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.edit-row { display: flex; gap: var(--sp-2); }
|
||||||
|
.edit-input {
|
||||||
|
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||||
|
color: var(--text-primary); font-size: var(--text-sm);
|
||||||
|
outline: none; transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.edit-input:focus { border-color: var(--border-focus); }
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
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);
|
||||||
|
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.action-btn-dim {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 12px; border-radius: var(--radius-md);
|
||||||
|
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.multi-list { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.multi-item {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 6px var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
|
||||||
|
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.multi-check {
|
||||||
|
width: 14px; height: 14px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); background: var(--bg-base);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; color: var(--accent-fg);
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
</style>
|
||||||
@@ -2,19 +2,26 @@
|
|||||||
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
|
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
import { timeAgo } from "../lib/homeHelpers";
|
import { timeAgo } from "../lib/homeHelpers";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
entries,
|
entries,
|
||||||
|
libraryManga,
|
||||||
onresume,
|
onresume,
|
||||||
onviewhistory,
|
onviewhistory,
|
||||||
onopenlibrary,
|
onopenlibrary,
|
||||||
}: {
|
}: {
|
||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
|
libraryManga: Manga[];
|
||||||
onresume: (entry: HistoryEntry) => void;
|
onresume: (entry: HistoryEntry) => void;
|
||||||
onviewhistory: () => void;
|
onviewhistory: () => void;
|
||||||
onopenlibrary: () => void;
|
onopenlibrary: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function thumbFor(entry: HistoryEntry): string {
|
||||||
|
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -31,7 +38,7 @@
|
|||||||
{#if entries.length > 0}
|
{#if entries.length > 0}
|
||||||
{#each entries as entry (entry.chapterId)}
|
{#each entries as entry (entry.chapterId)}
|
||||||
<button class="row" onclick={() => onresume(entry)}>
|
<button class="row" onclick={() => onresume(entry)}>
|
||||||
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" />
|
<Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
|
||||||
<div class="row-info">
|
<div class="row-info">
|
||||||
<span class="row-title">{entry.mangaTitle}</span>
|
<span class="row-title">{entry.mangaTitle}</span>
|
||||||
<span class="row-sub">
|
<span class="row-sub">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "@api/client";
|
import { gql, resolveImageUrl } from "@api/client";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
|
||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
@@ -87,37 +86,33 @@
|
|||||||
|
|
||||||
let activeIdx = $state(0);
|
let activeIdx = $state(0);
|
||||||
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||||
const heroThumbSrc = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
|
||||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
|
||||||
);
|
|
||||||
let heroThumb = $state("");
|
|
||||||
$effect(() => {
|
|
||||||
const path = heroThumbSrc;
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (!path) { heroThumb = ""; return; }
|
|
||||||
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
|
||||||
getBlobUrl(thumbUrl(path))
|
|
||||||
.then(url => { heroThumb = url; })
|
|
||||||
.catch(() => { heroThumb = ""; });
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroTitle = $derived(
|
const heroManga = $derived(
|
||||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
|
|
||||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
|
|
||||||
);
|
|
||||||
const heroManga = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
||||||
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
||||||
);
|
);
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
|
||||||
|
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
|
||||||
|
|
||||||
|
const heroThumbSrc = $derived(
|
||||||
|
heroManga?.thumbnailUrl
|
||||||
|
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
|
||||||
|
?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
let heroThumb = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const path = heroThumbSrc;
|
||||||
|
if (!path) { heroThumb = ""; return; }
|
||||||
|
resolveImageUrl(path)
|
||||||
|
.then(url => { heroThumb = url; })
|
||||||
|
.catch(() => { heroThumb = ""; });
|
||||||
|
});
|
||||||
|
|
||||||
const heroNewChapter = $derived(
|
const heroNewChapter = $derived(
|
||||||
heroManga
|
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
|
||||||
? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null
|
|
||||||
: null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
@@ -161,6 +156,10 @@
|
|||||||
|
|
||||||
let resuming = $state(false);
|
let resuming = $state(false);
|
||||||
|
|
||||||
|
function liveMangaStub(): Manga {
|
||||||
|
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
|
||||||
|
}
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
async function openChapter(chapter: Chapter) {
|
||||||
if (!heroMangaId) return;
|
if (!heroMangaId) return;
|
||||||
resuming = true;
|
resuming = true;
|
||||||
@@ -171,13 +170,12 @@
|
|||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
if (all.length) {
|
if (all.length) {
|
||||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
store.activeManga = liveMangaStub();
|
||||||
store.activeManga = manga;
|
|
||||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||||
if (target) openReader(target, list);
|
if (target) openReader(target, list);
|
||||||
}
|
}
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = liveMangaStub(); }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,24 +191,24 @@
|
|||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||||
if (ch) {
|
if (ch) {
|
||||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
store.activeManga = liveMangaStub();
|
||||||
openReader(ch, list);
|
openReader(ch, list);
|
||||||
}
|
}
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = liveMangaStub(); }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
async function resumeEntry(entry: HistoryEntry) {
|
||||||
|
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
|
||||||
|
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||||
if (ch) {
|
store.activeManga = stub;
|
||||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
if (ch) openReader(ch, list);
|
||||||
openReader(ch, list);
|
} catch { store.activeManga = stub; }
|
||||||
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
@@ -259,6 +257,7 @@
|
|||||||
<div class="mid-left">
|
<div class="mid-left">
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
entries={recentHistory}
|
entries={recentHistory}
|
||||||
|
{libraryManga}
|
||||||
onresume={resumeEntry}
|
onresume={resumeEntry}
|
||||||
onviewhistory={() => setNavPage("history")}
|
onviewhistory={() => setNavPage("history")}
|
||||||
onopenlibrary={() => setNavPage("library")}
|
onopenlibrary={() => setNavPage("library")}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
store, setCategories, setLibraryUpdates, addToast,
|
store, setCategories, setLibraryUpdates, addToast,
|
||||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
||||||
} from "../store/libraryState.svelte";
|
} from "../store/libraryState.svelte";
|
||||||
|
import { saveScroll, getScroll } from "@store/state.svelte";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
||||||
import type { Manga, Category, Chapter } from "@types";
|
import type { Manga, Category, Chapter } from "@types";
|
||||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
||||||
@@ -84,15 +85,47 @@
|
|||||||
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||||
|
|
||||||
|
const BUILTIN_TABS = ["library", "downloaded"] as const;
|
||||||
|
|
||||||
|
const completedCatId = $derived(
|
||||||
|
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTabIds = $derived((() => {
|
||||||
|
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||||
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
|
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
||||||
|
const ordered = [...pinned.filter(id => known.has(id))];
|
||||||
|
const inOrder = new Set(ordered);
|
||||||
|
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
||||||
|
if (!inOrder.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
|
||||||
|
|
||||||
|
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
|
||||||
|
|
||||||
|
const virtualTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
|
const folderTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
const visibleCategories = $derived((() => {
|
const visibleCategories = $derived((() => {
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||||
return store.categories
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
const cats = store.categories.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)));
|
||||||
.sort((a, b) => {
|
const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
|
||||||
if (a.id === defaultId) return -1;
|
return cats.sort((a, b) => {
|
||||||
if (b.id === defaultId) return 1;
|
if (a.id === defaultId) return -1;
|
||||||
return a.order - b.order;
|
if (b.id === defaultId) return 1;
|
||||||
});
|
const pd = pinOrder(a.id) - pinOrder(b.id);
|
||||||
|
return pd !== 0 ? pd : a.order - b.order;
|
||||||
|
});
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const categoryMangaMap = $derived((() => {
|
const categoryMangaMap = $derived((() => {
|
||||||
@@ -171,7 +204,18 @@
|
|||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
||||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
||||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
let prevTab = $state(tab);
|
||||||
|
$effect(() => {
|
||||||
|
const nextTab = tab;
|
||||||
|
if (scrollEl && nextTab !== prevTab) {
|
||||||
|
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
|
||||||
|
const saved = getScroll(`library:${nextTab}`);
|
||||||
|
untrack(() => { scrollEl.scrollTo({ top: saved }); });
|
||||||
|
prevTab = nextTab;
|
||||||
|
} else if (scrollEl && nextTab === prevTab) {
|
||||||
|
scrollEl.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const f = tab;
|
const f = tab;
|
||||||
if (f === "library" || f === "downloaded") return;
|
if (f === "library" || f === "downloaded") return;
|
||||||
@@ -179,7 +223,7 @@
|
|||||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
||||||
});
|
});
|
||||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
||||||
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); });
|
$effect(() => { tab; counts; });
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -188,13 +232,6 @@
|
|||||||
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateTabIndicator() {
|
|
||||||
if (!tabsEl) return;
|
|
||||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
|
||||||
if (!active) return;
|
|
||||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
|
||||||
}
|
|
||||||
|
|
||||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
||||||
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
||||||
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
||||||
@@ -503,17 +540,21 @@
|
|||||||
dragInsertIdx = -1;
|
dragInsertIdx = -1;
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
||||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||||
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
const dragStrId = String(dragId);
|
||||||
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
const tabs = [...visibleTabIds];
|
||||||
|
const fromIdx = tabs.indexOf(dragStrId);
|
||||||
if (fromIdx < 0) return;
|
if (fromIdx < 0) return;
|
||||||
const reordered = [...sorted];
|
tabs.splice(fromIdx, 1);
|
||||||
const [moved] = reordered.splice(fromIdx, 1);
|
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length));
|
||||||
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, reordered.length));
|
tabs.splice(dest, 0, dragStrId);
|
||||||
reordered.splice(dest, 0, moved);
|
updateSettings({ libraryPinnedTabOrder: tabs });
|
||||||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
||||||
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||||
|
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
||||||
|
setCategories([...zeroCat, ...reordered]);
|
||||||
|
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||||
try {
|
try {
|
||||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: dest + 1 });
|
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos });
|
||||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +582,6 @@
|
|||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
document.addEventListener("mousedown", onDocMouseDown, true);
|
||||||
requestAnimationFrame(updateTabIndicator);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect(); unsub();
|
ro.disconnect(); unsub();
|
||||||
@@ -600,8 +640,11 @@
|
|||||||
{tabFilters}
|
{tabFilters}
|
||||||
{hasActiveFilters}
|
{hasActiveFilters}
|
||||||
{anims}
|
{anims}
|
||||||
{tabIndicator}
|
|
||||||
{visibleCategories}
|
{visibleCategories}
|
||||||
|
{visibleTabIds}
|
||||||
|
{virtualTabIds}
|
||||||
|
{folderTabIds}
|
||||||
|
{completedCatId}
|
||||||
{counts}
|
{counts}
|
||||||
{search}
|
{search}
|
||||||
{refreshing}
|
{refreshing}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X,
|
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||||
hasActiveFilters: boolean;
|
hasActiveFilters: boolean;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
tabIndicator: { left: number; width: number };
|
|
||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
|
visibleTabIds: string[];
|
||||||
|
virtualTabIds: string[];
|
||||||
|
folderTabIds: string[];
|
||||||
|
completedCatId: number | null;
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
search: string;
|
search: string;
|
||||||
refreshing: boolean;
|
|
||||||
refreshProgress: { finished: number; total: number };
|
|
||||||
refreshDone: boolean;
|
|
||||||
refreshingCatId: number | null;
|
|
||||||
activeDragKind: "tab" | null;
|
activeDragKind: "tab" | null;
|
||||||
dragInsertIdx: number;
|
dragInsertIdx: number;
|
||||||
dragTabId: number | null;
|
dragTabId: number | null;
|
||||||
@@ -39,9 +38,6 @@
|
|||||||
onFiltersClear: () => void;
|
onFiltersClear: () => void;
|
||||||
onSortPanelToggle: () => void;
|
onSortPanelToggle: () => void;
|
||||||
onFilterPanelToggle: () => void;
|
onFilterPanelToggle: () => void;
|
||||||
onRefresh: () => void;
|
|
||||||
onCancelRefresh: () => void;
|
|
||||||
onRefreshCategory: (catId: number) => void;
|
|
||||||
onOpenDownloadsFolder: () => void;
|
onOpenDownloadsFolder: () => void;
|
||||||
onTabDragStart: (e: DragEvent, cat: Category) => void;
|
onTabDragStart: (e: DragEvent, cat: Category) => void;
|
||||||
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
|
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
|
||||||
@@ -52,16 +48,36 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||||
anims, tabIndicator, visibleCategories, counts, search, refreshing,
|
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||||
refreshProgress, refreshDone, refreshingCatId, activeDragKind, dragInsertIdx,
|
counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
|
||||||
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||||
tabsEl = $bindable(),
|
tabsEl = $bindable(),
|
||||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||||
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
|
onRefresh, onOpenDownloadsFolder,
|
||||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function onTabsWheel(e: WheelEvent) {
|
||||||
|
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
||||||
|
const idx = ids.indexOf(tab);
|
||||||
|
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
||||||
|
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tab;
|
||||||
|
if (!tabsEl) return;
|
||||||
|
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||||
|
if (!active) return;
|
||||||
|
const pl = tabsEl.scrollLeft;
|
||||||
|
const cw = tabsEl.clientWidth;
|
||||||
|
const ol = active.offsetLeft;
|
||||||
|
const ow = active.offsetWidth;
|
||||||
|
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
|
||||||
|
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||||||
az: "A–Z",
|
az: "A–Z",
|
||||||
unreadCount: "Unread chapters",
|
unreadCount: "Unread chapters",
|
||||||
@@ -75,70 +91,43 @@
|
|||||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeCatId = $derived(
|
|
||||||
tab !== "library" && tab !== "downloaded" ? Number(tab) : null
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Library</span>
|
<span class="heading">Library</span>
|
||||||
|
|
||||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
|
||||||
{#if anims && tabIndicator.width > 0}
|
{#each visibleTabIds as id, idx}
|
||||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||||
{/if}
|
{#if id === "library" || id === "downloaded" || cat}
|
||||||
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
|
{#if activeDragKind === "tab" && dragInsertIdx === idx}
|
||||||
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
{/if}
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
<button
|
||||||
{label}
|
class="tab"
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
class:active={tab === id}
|
||||||
</button>
|
class:tab-dragging={cat && dragTabId === cat.id}
|
||||||
|
draggable={!!cat && id !== String(completedCatId)}
|
||||||
|
onclick={() => onTabChange(id)}
|
||||||
|
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
|
||||||
|
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
|
||||||
|
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
|
||||||
|
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
|
||||||
|
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
|
||||||
|
>
|
||||||
|
{#if id === "library"}<Books size={11} weight="bold" />
|
||||||
|
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||||
|
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
|
||||||
|
{:else if cat}<Folder size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
|
||||||
|
<span class="tab-count">{counts[id] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
|
||||||
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if visibleCategories.length > 0}
|
|
||||||
<div class="tab-separator" aria-hidden="true"></div>
|
|
||||||
<div class="tabs-scroll">
|
|
||||||
{#each visibleCategories as cat, idx}
|
|
||||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:active={tab === String(cat.id)}
|
|
||||||
class:tab-dragging={dragTabId === cat.id}
|
|
||||||
draggable="true"
|
|
||||||
onclick={() => onTabChange(String(cat.id))}
|
|
||||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
|
||||||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
|
||||||
ondragleave={onTabDragLeave}
|
|
||||||
ondrop={(e) => onTabDrop(e, cat)}
|
|
||||||
ondragend={onTabDragEnd}
|
|
||||||
>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{cat.name}
|
|
||||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
|
||||||
{#if tab === String(cat.id) && !refreshing}
|
|
||||||
<span
|
|
||||||
class="tab-refresh"
|
|
||||||
role="button"
|
|
||||||
tabindex="-1"
|
|
||||||
title="Refresh {cat.name}"
|
|
||||||
aria-label="Refresh {cat.name}"
|
|
||||||
class:tab-refresh-spinning={refreshingCatId === cat.id}
|
|
||||||
onclick={(e) => { e.stopPropagation(); onRefreshCategory(cat.id); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onRefreshCategory(cat.id); } }}
|
|
||||||
>
|
|
||||||
<ArrowsClockwise size={10} weight="bold" class={refreshingCatId === cat.id ? "anim-spin" : ""} />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -150,10 +139,10 @@
|
|||||||
{#if refreshing}
|
{#if refreshing}
|
||||||
<button
|
<button
|
||||||
class="icon-btn refresh-btn icon-btn-active"
|
class="icon-btn refresh-btn icon-btn-active"
|
||||||
title="Cancel update"
|
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
|
||||||
onclick={onCancelRefresh}
|
onclick={onRefresh}
|
||||||
>
|
>
|
||||||
<X size={15} weight="bold" />
|
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
|
||||||
{#if refreshProgress.total > 0}
|
{#if refreshProgress.total > 0}
|
||||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -229,22 +218,14 @@
|
|||||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; }
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
|
||||||
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
.tabs-scroll::-webkit-scrollbar { display: none; }
|
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||||
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
|
|
||||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
|
||||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; flex-shrink: 0; }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
.tabs-anims .tab.active { background: transparent; }
|
|
||||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
.tab-refresh { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 2px; opacity: 0; color: var(--accent-fg); cursor: pointer; transition: opacity var(--t-base), background var(--t-base); flex-shrink: 0; }
|
|
||||||
.tab.active:hover .tab-refresh { opacity: 0.6; }
|
|
||||||
.tab.active:hover .tab-refresh:hover { opacity: 1; background: var(--accent-dim); }
|
|
||||||
.tab-refresh-spinning { opacity: 1 !important; }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||||
@@ -253,7 +234,9 @@
|
|||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||||
|
.refresh-btn:disabled { cursor: default; }
|
||||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||||
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
||||||
.sort-panel-wrap { position: relative; }
|
.sort-panel-wrap { position: relative; }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from "phosphor-svelte";
|
import { X } from "phosphor-svelte";
|
||||||
import { setPref } from "@features/series/lib/mangaPrefs";
|
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||||
import { store } from "@store/state.svelte";
|
import { store, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
|
|
||||||
let { ids, onClose }: {
|
let { ids, onClose }: {
|
||||||
ids: Set<number>;
|
ids: Set<number>;
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
||||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
||||||
|
|
||||||
|
const mosaicCovers = $derived.by(() => {
|
||||||
|
const idArr = [...ids].slice(0, 9);
|
||||||
|
return idArr
|
||||||
|
.map(id => store.library?.find(m => m.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(m => resolvedCover(m!.id, m!.thumbnailUrl));
|
||||||
|
});
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
||||||
@@ -60,11 +68,25 @@
|
|||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="header-left">
|
<div class="header-inner">
|
||||||
<span class="modal-title">Automation</span>
|
<div class="header-left">
|
||||||
<span class="modal-subtitle">{ids.size} series selected</span>
|
{#if mosaicCovers.length > 0}
|
||||||
|
<div class="mosaic" aria-hidden="true">
|
||||||
|
{#each mosaicCovers.slice(0, 5) as src}
|
||||||
|
<img class="mosaic-tile" {src} alt="" />
|
||||||
|
{/each}
|
||||||
|
{#if ids.size > 5}
|
||||||
|
<span class="mosaic-overflow">+{ids.size - 5}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="header-text">
|
||||||
|
<span class="modal-title">Automation</span>
|
||||||
|
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -214,13 +236,37 @@
|
|||||||
animation: scaleIn 0.15s ease both;
|
animation: scaleIn 0.15s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header { border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
padding: var(--sp-4) var(--sp-5); gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
.header-left { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||||
|
|
||||||
|
.mosaic {
|
||||||
|
display: flex; align-items: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mosaic-tile {
|
||||||
|
width: 28px; height: 38px;
|
||||||
|
object-fit: cover; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
margin-left: -6px; box-shadow: -1px 0 0 var(--bg-surface);
|
||||||
|
}
|
||||||
|
.mosaic-tile:first-child { margin-left: 0; }
|
||||||
|
|
||||||
|
.mosaic-overflow {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: var(--sp-1); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
chapterEpoch: number;
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
@@ -31,10 +32,152 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
|
||||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
||||||
|
|
||||||
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
|
const out: FlatPage[] = [];
|
||||||
|
for (const chunk of stripToRender) {
|
||||||
|
for (let i = 0; i < chunk.urls.length; i++) {
|
||||||
|
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let loadedSet = $state(new Set<number>());
|
||||||
|
let resolvedSrc = $state<Record<number, string>>({});
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
const elementIndex = new Map<Element, number>();
|
||||||
|
|
||||||
|
let viewportCenter = $state(0);
|
||||||
|
|
||||||
|
function scheduleRevoke(src: string) {
|
||||||
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
|
revokeQueue.push(src);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const url = revokeQueue.shift();
|
||||||
|
if (url) {
|
||||||
|
try { URL.revokeObjectURL(url); } catch { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(idx: number) {
|
||||||
|
if (loadedSet.has(idx)) return;
|
||||||
|
const page = flatPages[idx];
|
||||||
|
if (!page) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.add(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
||||||
|
resolveUrl(page.url, priority).then(src => {
|
||||||
|
if (loadedSet.has(idx)) {
|
||||||
|
resolvedSrc = { ...resolvedSrc, [idx]: src };
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!loadedSet.has(idx)) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.delete(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const oldSrc = resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
const next = { ...resolvedSrc };
|
||||||
|
delete next[idx];
|
||||||
|
resolvedSrc = next;
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcWindow() {
|
||||||
|
const center = viewportCenter;
|
||||||
|
const lo = center - LOAD_RADIUS;
|
||||||
|
const hi = center + LOAD_RADIUS;
|
||||||
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
|
const evictHi = center + UNLOAD_RADIUS;
|
||||||
|
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (i >= lo && i <= hi) {
|
||||||
|
loadPage(i);
|
||||||
|
} else if (i < evictLo || i > evictHi) {
|
||||||
|
unloadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void viewportCenter;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void flatPages.length;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupObserver(containerEl: HTMLElement) {
|
||||||
|
observer?.disconnect();
|
||||||
|
elementIndex.clear();
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let best = -1;
|
||||||
|
let bestRatio = -1;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const idx = elementIndex.get(entry.target);
|
||||||
|
if (idx === undefined) continue;
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
||||||
|
bestRatio = entry.intersectionRatio;
|
||||||
|
best = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0 && best !== viewportCenter) {
|
||||||
|
viewportCenter = best;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: containerEl,
|
||||||
|
rootMargin: "0px",
|
||||||
|
threshold: [0, 0.1, 0.5, 1.0],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observePage(el: HTMLDivElement, idx: number) {
|
||||||
|
elementIndex.set(el, idx);
|
||||||
|
observer?.observe(el);
|
||||||
|
return {
|
||||||
|
update(newIdx: number) {
|
||||||
|
elementIndex.set(el, newIdx);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
observer?.unobserve(el);
|
||||||
|
elementIndex.delete(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void chapterEpoch;
|
||||||
|
loadedSet = new Set<number>();
|
||||||
|
resolvedSrc = {};
|
||||||
|
const resume = readerState.resumePage;
|
||||||
|
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
@@ -86,6 +229,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function onInspectMouseDown(e: MouseEvent) {
|
export function onInspectMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
stripDragging = true;
|
stripDragging = true;
|
||||||
stripDragMoved = false;
|
stripDragMoved = false;
|
||||||
@@ -126,6 +270,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerDown(e: PointerEvent) {
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
pinch?.onPointerDown(e);
|
pinch?.onPointerDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +337,17 @@
|
|||||||
function setContainer(el: HTMLDivElement) {
|
function setContainer(el: HTMLDivElement) {
|
||||||
containerEl = el;
|
containerEl = el;
|
||||||
bindContainer(el);
|
bindContainer(el);
|
||||||
|
if (style === "longstrip") setupObserver(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" && containerEl) {
|
||||||
|
setupObserver(containerEl);
|
||||||
|
} else if (style !== "longstrip") {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -219,53 +374,78 @@
|
|||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
{#key chapterEpoch}
|
||||||
{#each stripToRender as chunk}
|
{#if style === "longstrip"}
|
||||||
{#each chunk.urls as url, i}
|
{#each flatPages as page, gi}
|
||||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
{@const src = resolvedSrc[gi]}
|
||||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
{:then src}
|
<div
|
||||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
class="strip-slot"
|
||||||
{/await}
|
use:observePage={gi}
|
||||||
{/each}
|
data-gi={gi}
|
||||||
{/each}
|
>
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
{#if isLoaded}
|
||||||
|
<img
|
||||||
{:else if style === "fade" && pageReady}
|
src={src ?? ""}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
data-local-page={page.localIndex + 1}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
data-chapter={page.chapterId}
|
||||||
{:then src}
|
data-total={page.total}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
{/await}
|
loading="eager"
|
||||||
</div>
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
{:else if style === "double" && pageReady}
|
onload={(e) => {
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
{#if pageGroups.length}
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
<div class="double-wrap">
|
if (slot && img.naturalWidth > 0) {
|
||||||
{#each currentGroup as pg, i}
|
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
}
|
||||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
}}
|
||||||
{:then src}
|
/>
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
{:else}
|
||||||
{/await}
|
<div class="strip-placeholder"></div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pageReady}
|
{:else if style === "fade" && pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" draggable="false" />
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
{:else if style === "double" && pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#if pageGroups.length}
|
||||||
|
<div class="double-wrap">
|
||||||
|
{#each currentGroup as pg, i}
|
||||||
|
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||||
|
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||||
|
{/await}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
|
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,6 +460,20 @@
|
|||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.strip-slot {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-placeholder {
|
||||||
|
width: var(--effective-width, 100%);
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
|
|
||||||
const effectiveReaderSettings = $derived.by(() => {
|
const effectiveReaderSettings = $derived.by(() => {
|
||||||
const mangaId = store.activeManga?.id;
|
const mangaId = store.activeManga?.id;
|
||||||
@@ -420,23 +420,28 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
const current = store.pageUrls[store.pageNumber - 1];
|
const current = store.pageUrls[store.pageNumber - 1];
|
||||||
|
const pageNum = store.pageNumber;
|
||||||
|
const urls = store.pageUrls;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
if (useBlob) {
|
const t = setTimeout(() => {
|
||||||
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
if (useBlob) {
|
||||||
getBlobUrl(current, 999);
|
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
getBlobUrl(current, 999);
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
||||||
preloadBlobUrls(upcoming, ahead);
|
const behind = urls[pageNum - 2];
|
||||||
if (behind) preloadBlobUrls([behind], 0);
|
preloadBlobUrls(upcoming, ahead);
|
||||||
});
|
if (behind) preloadBlobUrls([behind], 0);
|
||||||
} else {
|
});
|
||||||
for (let i = 1; i <= ahead; i++) {
|
} else {
|
||||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
for (let i = 1; i <= ahead; i++) {
|
||||||
if (url) preloadImage(url, useBlob);
|
const url = urls[pageNum - 1 + i];
|
||||||
|
if (url) preloadImage(url, useBlob);
|
||||||
|
}
|
||||||
|
const behind = urls[pageNum - 2];
|
||||||
|
if (behind) preloadImage(behind, useBlob);
|
||||||
}
|
}
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
}, 150);
|
||||||
if (behind) preloadImage(behind, useBlob);
|
return () => clearTimeout(t);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
class:bar-left={barPosition === "left"}
|
class:bar-left={barPosition === "left"}
|
||||||
class:bar-right={barPosition === "right"}
|
class:bar-right={barPosition === "right"}
|
||||||
class:hidden={!uiVisible}
|
class:hidden={!uiVisible}
|
||||||
|
data-tauri-drag-region={barPosition === "top" ? true : undefined}
|
||||||
>
|
>
|
||||||
<div class="bar-start">
|
<div class="bar-start">
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
@@ -142,12 +143,18 @@
|
|||||||
{#if isVertical}
|
{#if isVertical}
|
||||||
<span class="ch-info"></span>
|
<span class="ch-info"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="ch-title">{store.activeManga?.title}</span>
|
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-marquee-content">
|
||||||
<span class="ch-name">{displayChapter?.name}</span>
|
<span class="ch-title">{store.activeManga?.title}</span>
|
||||||
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
<span class="ch-sep">/</span>
|
||||||
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if !isVertical}
|
||||||
|
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if chapterHover && isVertical}
|
{#if chapterHover && isVertical}
|
||||||
<div class="ch-popover ch-popover-{popoverSide}">
|
<div class="ch-popover ch-popover-{popoverSide}">
|
||||||
@@ -360,6 +367,7 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: opacity 0.25s ease;
|
transition: opacity 0.25s ease;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.bar.hidden { opacity: 0; pointer-events: none; }
|
.bar.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
@@ -404,16 +412,14 @@
|
|||||||
.icon-btn.active { color: var(--accent-fg); }
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||||
|
|
||||||
.ch-hover-wrap { position: relative; min-width: 0; }
|
.ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.ch-pill {
|
.ch-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
@@ -429,9 +435,24 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
.ch-marquee-track {
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||||
|
.ch-marquee-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ch-name { color: var(--text-muted); }
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
.ch-popover {
|
.ch-popover {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { store, openReader } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import { cancelQueuedFetches } from "@core/cache/imageCache";
|
||||||
|
import { clearResolvedUrlCache } from "@core/cache/pageCache";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
@@ -19,6 +21,10 @@ export async function loadChapter(
|
|||||||
abortCtrl.current?.abort();
|
abortCtrl.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl.current = ctrl;
|
abortCtrl.current = ctrl;
|
||||||
|
|
||||||
|
cancelQueuedFetches();
|
||||||
|
if (useBlob) clearResolvedUrlCache();
|
||||||
|
|
||||||
startAtLastPage.current = false;
|
startAtLastPage.current = false;
|
||||||
markedRead.clear();
|
markedRead.clear();
|
||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
@@ -43,7 +49,7 @@ export async function loadChapter(
|
|||||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
readerState.loading = false;
|
||||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {});
|
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.error = e instanceof Error ? e.message : String(e);
|
readerState.error = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -25,57 +25,58 @@ export function setupScrollTracking(
|
|||||||
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||||
} = callbacks;
|
} = callbacks;
|
||||||
|
|
||||||
function onScroll() {
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
rafId = null;
|
||||||
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
if (!imgs.length) return;
|
if (!imgs.length) return;
|
||||||
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
|
|
||||||
let activePage: number | null = null;
|
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||||
let activeChId: number | null = null;
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
for (const img of imgs) {
|
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
||||||
if (img.getBoundingClientRect().top <= readLineY) {
|
else hi = mid - 1;
|
||||||
activePage = Number(img.dataset.localPage);
|
|
||||||
activeChId = Number(img.dataset.chapter);
|
|
||||||
} else break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePage === null) {
|
const active = imgs[best];
|
||||||
activePage = Number(imgs[0].dataset.localPage);
|
const activePage = Number(active.dataset.localPage);
|
||||||
activeChId = Number(imgs[0].dataset.chapter);
|
const activeChId = Number(active.dataset.chapter);
|
||||||
}
|
|
||||||
|
|
||||||
if (activePage !== null) onPageChange(activePage);
|
onPageChange(activePage);
|
||||||
if (activeChId) onChapterChange(activeChId);
|
if (activeChId) onChapterChange(activeChId);
|
||||||
|
|
||||||
if (shouldAutoMark() && activePage !== null && activeChId) {
|
if (shouldAutoMark() && activeChId) {
|
||||||
const chunks = getStripChapters();
|
const chunks = getStripChapters();
|
||||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||||
|
|
||||||
|
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
||||||
|
if (atBottom) {
|
||||||
|
const last = chunks[chunks.length - 1];
|
||||||
|
if (last) onMarkRead(last.chapterId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
|
||||||
if (atBottom && shouldAutoMark()) {
|
|
||||||
const chunks = getStripChapters();
|
|
||||||
const last = chunks[chunks.length - 1];
|
|
||||||
if (last) onMarkRead(last.chapterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScrollAppend() {
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.80) onAppend();
|
if (pct >= 0.80) onAppend();
|
||||||
}
|
}
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
function onScroll() {
|
||||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
containerEl.removeEventListener("scroll", onScroll);
|
||||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
enqueueing: Set<number>;
|
enqueueing: Set<number>;
|
||||||
chapterPage: number;
|
chapterPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
scrollEl?: HTMLDivElement | null;
|
||||||
onOpen: (ch: Chapter, inProgress: boolean) => void;
|
onOpen: (ch: Chapter, inProgress: boolean) => void;
|
||||||
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
|
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
|
||||||
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
|
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
let {
|
let {
|
||||||
pageChapters, sortedChapters, viewMode, loadingChapters,
|
pageChapters, sortedChapters, viewMode, loadingChapters,
|
||||||
selectedIds, enqueueing, chapterPage, totalPages,
|
selectedIds, enqueueing, chapterPage, totalPages,
|
||||||
|
scrollEl = $bindable(null),
|
||||||
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
||||||
onPageChange, buildCtxItems,
|
onPageChange, buildCtxItems,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
|
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
|
||||||
{#if loadingChapters && sortedChapters.length === 0}
|
{#if loadingChapters && sortedChapters.length === 0}
|
||||||
{#if viewMode === "grid"}
|
{#if viewMode === "grid"}
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
addBookmark, acknowledgeUpdate,
|
addBookmark, acknowledgeUpdate,
|
||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||||
clearMarkersForManga,
|
clearMarkersForManga,
|
||||||
|
saveScroll, getScroll,
|
||||||
} from "@store/state.svelte";
|
} from "@store/state.svelte";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
@@ -102,8 +103,8 @@
|
|||||||
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
const readCount = $derived(sortedChapters.filter(c => c.isRead).length);
|
||||||
const totalCount = $derived(chapters.length);
|
const totalCount = $derived(sortedChapters.length);
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
||||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
||||||
|
|
||||||
@@ -583,6 +584,20 @@
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chapterListEl: HTMLDivElement | null = $state(null);
|
||||||
|
let prevMangaId: number | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const mangaId = store.activeManga?.id ?? null;
|
||||||
|
if (mangaId === prevMangaId) return;
|
||||||
|
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
|
||||||
|
prevMangaId = mangaId;
|
||||||
|
if (chapterListEl && mangaId !== null) {
|
||||||
|
const saved = getScroll(`series:${mangaId}`);
|
||||||
|
chapterListEl.scrollTo({ top: saved });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -665,6 +680,7 @@
|
|||||||
{enqueueing}
|
{enqueueing}
|
||||||
{chapterPage}
|
{chapterPage}
|
||||||
{totalPages}
|
{totalPages}
|
||||||
|
bind:scrollEl={chapterListEl}
|
||||||
onOpen={openReaderWithAhead}
|
onOpen={openReaderWithAhead}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onEnqueue={enqueue}
|
onEnqueue={enqueue}
|
||||||
@@ -689,7 +705,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if autoOpen && store.activeManga}
|
{#if autoOpen && store.activeManga}
|
||||||
<AutomationPanel mangaId={store.activeManga.id} onClose={() => autoOpen = false} />
|
<AutomationPanel mangaId={store.activeManga.id} manga={store.activeManga} onClose={() => autoOpen = false} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if markersOpen && store.activeManga}
|
{#if markersOpen && store.activeManga}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
|
|
||||||
let manageOpen: boolean = $state(false);
|
let manageOpen: boolean = $state(false);
|
||||||
let genresExpanded: boolean = $state(false);
|
let genresExpanded: boolean = $state(false);
|
||||||
|
let altOpen: boolean = $state(false);
|
||||||
|
|
||||||
const statusLabel = $derived(
|
const statusLabel = $derived(
|
||||||
manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null
|
manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null
|
||||||
@@ -68,7 +69,9 @@
|
|||||||
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
|
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
const altTitles = $derived(
|
||||||
|
(manga as any)?.alternativeTitles ?? (manga as any)?.altTitles ?? []
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
@@ -76,9 +79,9 @@
|
|||||||
<ArrowLeft size={13} weight="light" /> Back
|
<ArrowLeft size={13} weight="light" /> Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="cover-wrap">
|
<button class="cover-wrap" onclick={() => setPreviewManga(manga)}>
|
||||||
<Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
|
<Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{#if loadingManga}
|
{#if loadingManga}
|
||||||
<div class="meta-skeleton">
|
<div class="meta-skeleton">
|
||||||
@@ -88,12 +91,36 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<p class="title">{manga?.title}</p>
|
<p class="title">{manga?.title}</p>
|
||||||
|
|
||||||
{#if manga?.author || manga?.artist}
|
{#if manga?.author || manga?.artist}
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusLabel}
|
|
||||||
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
<div class="badges">
|
||||||
|
{#if statusLabel}
|
||||||
|
<span class="badge" class:badge-ongoing={manga?.status === "ONGOING"} class:badge-ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
||||||
|
{/if}
|
||||||
|
{#if manga?.source?.displayName ?? (manga as any)?.source?.name}
|
||||||
|
<span class="badge badge-source">{manga?.source?.displayName ?? (manga as any)?.source?.name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if altTitles.length > 0}
|
||||||
|
<div class="alttitles-section">
|
||||||
|
<button class="row-toggle" onclick={() => altOpen = !altOpen}>
|
||||||
|
<span>Also known as</span>
|
||||||
|
<CaretDown size={10} weight="light" style="transform:{altOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease;flex-shrink:0" />
|
||||||
|
</button>
|
||||||
|
{#if altOpen}
|
||||||
|
<div class="alttitles-list">
|
||||||
|
{#each altTitles as t}
|
||||||
|
<p class="alttitle">{t}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if manga?.genre?.length}
|
{#if manga?.genre?.length}
|
||||||
<div class="genres">
|
<div class="genres">
|
||||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
||||||
@@ -106,8 +133,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if manga?.description}
|
{#if manga?.description}
|
||||||
<p class="desc">{manga.description}</p>
|
<div class="desc-wrap">
|
||||||
|
<p class="desc">{manga.description}</p>
|
||||||
|
<button class="expand-toggle" onclick={() => setPreviewManga(manga)}>Read more</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -197,11 +228,13 @@
|
|||||||
padding: var(--sp-5);
|
padding: var(--sp-5);
|
||||||
border-right: 1px solid var(--border-dim);
|
border-right: 1px solid var(--border-dim);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
}
|
}
|
||||||
|
.sidebar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
@@ -215,13 +248,17 @@
|
|||||||
width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md);
|
||||||
overflow: hidden; background: var(--bg-raised);
|
overflow: hidden; background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim); flex-shrink: 0;
|
border: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
cursor: pointer; transition: opacity var(--t-base);
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
.cover-wrap:hover { opacity: 0.88; }
|
||||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.sk-line { border-radius: var(--radius-sm); }
|
.sk-line { border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
color: var(--text-primary); line-height: var(--leading-snug);
|
color: var(--text-primary); line-height: var(--leading-snug);
|
||||||
@@ -229,13 +266,33 @@
|
|||||||
}
|
}
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
||||||
|
|
||||||
.status-badge {
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.badge {
|
||||||
display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs);
|
display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content;
|
padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content;
|
||||||
}
|
}
|
||||||
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
.badge-ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||||
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
.badge-ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
||||||
|
.badge-source {
|
||||||
|
background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim);
|
||||||
|
text-transform: none; letter-spacing: var(--tracking-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alttitles-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
.row-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; width: 100%;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide); padding: 2px 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.row-toggle:hover { color: var(--text-muted); }
|
||||||
|
.alttitles-list { display: flex; flex-direction: column; gap: 3px; padding-top: var(--sp-1); }
|
||||||
|
.alttitle {
|
||||||
|
font-size: var(--text-2xs); color: var(--text-faint); font-family: var(--font-ui);
|
||||||
|
line-height: var(--leading-snug); padding-left: var(--sp-1);
|
||||||
|
border-left: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
.genre {
|
.genre {
|
||||||
@@ -253,10 +310,17 @@
|
|||||||
}
|
}
|
||||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.desc-wrap { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
.desc {
|
.desc {
|
||||||
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
||||||
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.expand-toggle {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide); align-self: flex-start;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.expand-toggle:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.read-btn {
|
.read-btn {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from "phosphor-svelte";
|
import { X } from "phosphor-svelte";
|
||||||
import { getPref, setPref } from "../lib/mangaPrefs";
|
import { getPref, setPref } from "../lib/mangaPrefs";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types/index";
|
||||||
|
|
||||||
let { mangaId, onClose }: {
|
let { mangaId, manga: mangaProp = null, onClose }: {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
|
manga?: Manga | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -35,9 +40,19 @@
|
|||||||
{ value: "manual", label: "Manual" },
|
{ value: "manual", label: "Manual" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key);
|
const defaults = $derived(store.settings.automationDefaults);
|
||||||
|
|
||||||
|
function get<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||||
|
const pref = getPref(mangaId, key);
|
||||||
|
if (pref !== undefined) return pref;
|
||||||
|
return (defaults as MangaPrefs | undefined)?.[key] ?? getPref(mangaId, key);
|
||||||
|
}
|
||||||
|
|
||||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
|
||||||
|
|
||||||
|
const manga = $derived(store.library?.find(m => m.id === mangaId) ?? mangaProp);
|
||||||
|
const coverSrc = $derived(manga ? resolvedCover(manga.id, manga.thumbnailUrl) : null);
|
||||||
|
|
||||||
function onBackdrop(e: MouseEvent) {
|
function onBackdrop(e: MouseEvent) {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}
|
}
|
||||||
@@ -46,135 +61,150 @@
|
|||||||
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="cover-col">
|
||||||
<div class="header-left">
|
{#if coverSrc}
|
||||||
<span class="modal-title">Automation</span>
|
<div class="cover-wrap">
|
||||||
<span class="modal-subtitle">Per-series rules</span>
|
<Thumbnail src={coverSrc} alt={manga?.title} class="cover" />
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
{:else}
|
||||||
|
<div class="cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="content">
|
||||||
|
<div class="content-header">
|
||||||
<p class="section-label">Downloads</p>
|
<div class="title-block">
|
||||||
|
<span class="title">{manga?.title ?? "Automation"}</span>
|
||||||
<div class="auto-row">
|
<span class="subtitle">Per-series rules</span>
|
||||||
<div class="auto-info">
|
|
||||||
<span class="auto-label">Auto-download new chapters</span>
|
|
||||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||||
role="switch"
|
<X size={16} weight="light" />
|
||||||
aria-checked={get("autoDownload")}
|
</button>
|
||||||
aria-label="Auto-download new chapters"
|
|
||||||
class="auto-toggle"
|
|
||||||
class:auto-toggle-on={get("autoDownload")}
|
|
||||||
onclick={() => set("autoDownload", !get("autoDownload"))}
|
|
||||||
><span class="auto-toggle-thumb"></span></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auto-row">
|
<div class="content-body">
|
||||||
<div class="auto-info">
|
|
||||||
<span class="auto-label">Download ahead</span>
|
|
||||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
|
||||||
</div>
|
|
||||||
<div class="auto-chip-group">
|
|
||||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
|
||||||
<button
|
|
||||||
class="auto-chip"
|
|
||||||
class:auto-chip-on={get("downloadAhead") === opt.value}
|
|
||||||
onclick={() => set("downloadAhead", opt.value)}
|
|
||||||
>{opt.label}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auto-row">
|
<p class="section-label">Downloads</p>
|
||||||
<div class="auto-info">
|
|
||||||
<span class="auto-label">Max chapters to keep</span>
|
<div class="auto-row">
|
||||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Auto-download new chapters</span>
|
||||||
|
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("autoDownload")}
|
||||||
|
aria-label="Auto-download new chapters"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("autoDownload")}
|
||||||
|
onclick={() => set("autoDownload", !get("autoDownload"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-chip-group">
|
|
||||||
{#each MAX_KEEP_OPTIONS as opt}
|
|
||||||
<button
|
|
||||||
class="auto-chip"
|
|
||||||
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
|
||||||
onclick={() => set("maxKeepChapters", opt.value)}
|
|
||||||
>{opt.label}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="auto-row auto-row-col">
|
||||||
|
<div class="auto-info">
|
||||||
<p class="section-label">On Read</p>
|
<span class="auto-label">Download ahead</span>
|
||||||
|
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||||
<div class="auto-row">
|
</div>
|
||||||
<div class="auto-info">
|
|
||||||
<span class="auto-label">Delete after reading</span>
|
|
||||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
role="switch"
|
|
||||||
aria-checked={get("deleteOnRead")}
|
|
||||||
aria-label="Delete after reading"
|
|
||||||
class="auto-toggle"
|
|
||||||
class:auto-toggle-on={get("deleteOnRead")}
|
|
||||||
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
|
||||||
><span class="auto-toggle-thumb"></span></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if get("deleteOnRead")}
|
|
||||||
<div class="auto-row auto-row-sub">
|
|
||||||
<span class="auto-label">Delete delay</span>
|
|
||||||
<div class="auto-chip-group">
|
<div class="auto-chip-group">
|
||||||
{#each DELETE_DELAY_OPTIONS as opt}
|
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||||
<button
|
<button
|
||||||
class="auto-chip"
|
class="auto-chip"
|
||||||
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
class:auto-chip-on={get("downloadAhead") === opt.value}
|
||||||
onclick={() => set("deleteDelayHours", opt.value)}
|
onclick={() => set("downloadAhead", opt.value)}
|
||||||
>{opt.label}</button>
|
>{opt.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="auto-row auto-row-col">
|
||||||
|
<div class="auto-info">
|
||||||
<p class="section-label">Updates</p>
|
<span class="auto-label">Max chapters to keep</span>
|
||||||
|
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||||
<div class="auto-row">
|
</div>
|
||||||
<div class="auto-info">
|
<div class="auto-chip-group">
|
||||||
<span class="auto-label">Pause updates</span>
|
{#each MAX_KEEP_OPTIONS as opt}
|
||||||
<span class="auto-desc">Skip this series during global refresh</span>
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
||||||
|
onclick={() => set("maxKeepChapters", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
role="switch"
|
<div class="divider"></div>
|
||||||
aria-checked={get("pauseUpdates")}
|
|
||||||
aria-label="Pause updates"
|
<p class="section-label">On Read</p>
|
||||||
class="auto-toggle"
|
|
||||||
class:auto-toggle-on={get("pauseUpdates")}
|
<div class="auto-row">
|
||||||
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
<div class="auto-info">
|
||||||
><span class="auto-toggle-thumb"></span></button>
|
<span class="auto-label">Delete after reading</span>
|
||||||
|
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("deleteOnRead")}
|
||||||
|
aria-label="Delete after reading"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("deleteOnRead")}
|
||||||
|
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if get("deleteOnRead")}
|
||||||
|
<div class="auto-row auto-row-sub">
|
||||||
|
<span class="auto-label">Delete delay</span>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each DELETE_DELAY_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
||||||
|
onclick={() => set("deleteDelayHours", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="section-label">Updates</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Pause updates</span>
|
||||||
|
<span class="auto-desc">Skip this series during global refresh</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("pauseUpdates")}
|
||||||
|
aria-label="Pause updates"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("pauseUpdates")}
|
||||||
|
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row auto-row-col">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Refresh interval</span>
|
||||||
|
<span class="auto-desc">How often to check for new chapters</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("refreshInterval") === opt.value}
|
||||||
|
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auto-row">
|
|
||||||
<div class="auto-info">
|
|
||||||
<span class="auto-label">Refresh interval</span>
|
|
||||||
<span class="auto-desc">How often to check for new chapters</span>
|
|
||||||
</div>
|
|
||||||
<div class="auto-chip-group">
|
|
||||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
|
||||||
<button
|
|
||||||
class="auto-chip"
|
|
||||||
class:auto-chip-on={get("refreshInterval") === opt.value}
|
|
||||||
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
|
||||||
>{opt.label}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,31 +217,84 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: 420px; max-width: calc(100vw - var(--sp-6));
|
display: flex; flex-direction: row;
|
||||||
max-height: 80vh;
|
width: 600px; max-width: calc(100vw - var(--sp-6));
|
||||||
display: flex; flex-direction: column;
|
height: 480px; max-height: 85vh;
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||||
border-radius: var(--radius-xl); overflow: hidden;
|
border-radius: var(--radius-xl); overflow: hidden;
|
||||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
animation: scaleIn 0.15s ease both;
|
animation: scaleIn 0.15s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.cover-col {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
width: 200px; flex-shrink: 0;
|
||||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrap { position: relative; width: 100%; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
|
:global(.cover) {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover; object-position: center top;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm); color: var(--text-faint);
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
|
||||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
.modal-body {
|
.content-body {
|
||||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
padding: var(--sp-4) var(--sp-5);
|
padding: var(--sp-5) var(--sp-6);
|
||||||
}
|
}
|
||||||
.modal-body::-webkit-scrollbar { display: none; }
|
.content-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
@@ -222,7 +305,9 @@
|
|||||||
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.auto-row-col { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
|
||||||
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
|
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
|
||||||
|
|
||||||
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||||
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||||
@@ -232,7 +317,7 @@
|
|||||||
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||||
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||||
|
|
||||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
|
||||||
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|||||||
@@ -318,6 +318,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Select Dropdown ──────────────────────────────────────────────── */
|
/* ── Select Dropdown ──────────────────────────────────────────────── */
|
||||||
.s-select { position: relative; flex-shrink: 0; }
|
.s-select { position: relative; flex-shrink: 0; }
|
||||||
@@ -682,6 +688,16 @@
|
|||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-subsection-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: var(--sp-3) var(--sp-4) var(--sp-1);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Storage bar ──────────────────────────────────────────────────── */
|
/* ── Storage bar ──────────────────────────────────────────────────── */
|
||||||
.s-storage-wrap {
|
.s-storage-wrap {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck } from "phosphor-svelte";
|
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from "phosphor-svelte";
|
||||||
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
|
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
|
||||||
import { eventToKeybind } from "@core/keybinds/keybindEngine";
|
import { eventToKeybind } from "@core/keybinds/keybindEngine";
|
||||||
import type { Keybinds } from "@types/settings";
|
import type { Keybinds } from "@types/settings";
|
||||||
@@ -19,16 +19,18 @@
|
|||||||
import ContentSettings from "../sections/ContentSettings.svelte";
|
import ContentSettings from "../sections/ContentSettings.svelte";
|
||||||
import AboutSettings from "../sections/AboutSettings.svelte";
|
import AboutSettings from "../sections/AboutSettings.svelte";
|
||||||
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
|
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
|
||||||
|
import AutomationSettings from "../sections/AutomationSettings.svelte";
|
||||||
|
|
||||||
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
|
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
|
||||||
let { onOpenThemeEditor }: Props = $props();
|
let { onOpenThemeEditor }: Props = $props();
|
||||||
|
|
||||||
type Tab = "general"|"appearance"|"reader"|"library"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
|
type Tab = "general"|"appearance"|"reader"|"library"|"automation"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
|
||||||
const TABS: { id: Tab; label: string; icon: any }[] = [
|
const TABS: { id: Tab; label: string; icon: any }[] = [
|
||||||
{ id: "general", label: "General", icon: Gear },
|
{ id: "general", label: "General", icon: Gear },
|
||||||
{ id: "appearance", label: "Appearance", icon: PaintBrush },
|
{ id: "appearance", label: "Appearance", icon: PaintBrush },
|
||||||
{ id: "reader", label: "Reader", icon: Book },
|
{ id: "reader", label: "Reader", icon: Book },
|
||||||
{ id: "library", label: "Library", icon: Image },
|
{ id: "library", label: "Library", icon: Image },
|
||||||
|
{ id: "automation", label: "Automation", icon: Robot },
|
||||||
{ id: "performance", label: "Performance", icon: Sliders },
|
{ id: "performance", label: "Performance", icon: Sliders },
|
||||||
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
||||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
{ id: "storage", label: "Storage", icon: HardDrives },
|
||||||
@@ -61,7 +63,6 @@
|
|||||||
|
|
||||||
function close() { setSettingsOpen(false); }
|
function close() { setSettingsOpen(false); }
|
||||||
|
|
||||||
// Keybind capture
|
|
||||||
let listeningKey: keyof Keybinds | null = $state(null);
|
let listeningKey: keyof Keybinds | null = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -83,7 +84,6 @@
|
|||||||
return () => window.removeEventListener("keydown", capture, true);
|
return () => window.removeEventListener("keydown", capture, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shared select dropdown state (passed to sections that need it)
|
|
||||||
let selectOpen: string | null = $state(null);
|
let selectOpen: string | null = $state(null);
|
||||||
let closingSelect: string | null = $state(null);
|
let closingSelect: string | null = $state(null);
|
||||||
|
|
||||||
@@ -105,9 +105,7 @@
|
|||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (!selectOpen) return;
|
if (!selectOpen) return;
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
// Keep open if click is inside the trigger wrapper (.s-select)
|
|
||||||
if (t.closest(".s-select")) return;
|
if (t.closest(".s-select")) return;
|
||||||
// Keep open if click landed inside the portalled menu (appended to <body>)
|
|
||||||
if (t.closest(".s-select-menu")) return;
|
if (t.closest(".s-select-menu")) return;
|
||||||
closeSelect();
|
closeSelect();
|
||||||
};
|
};
|
||||||
@@ -167,6 +165,8 @@
|
|||||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||||
{:else if tab === "library"}
|
{:else if tab === "library"}
|
||||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||||
|
{:else if tab === "automation"}
|
||||||
|
<AutomationSettings />
|
||||||
{:else if tab === "performance"}
|
{:else if tab === "performance"}
|
||||||
<PerformanceSettings />
|
<PerformanceSettings />
|
||||||
{:else if tab === "keybinds"}
|
{:else if tab === "keybinds"}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
||||||
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
|
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
loadServerInfo();
|
loadServerInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,9 +95,9 @@
|
|||||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBuildTime(unix: number) {
|
function fmtBuildTime(unix: number | string) {
|
||||||
if (!unix) return "";
|
if (!unix) return "";
|
||||||
return new Date(unix).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return new Date(Number(unix) * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBytes(bytes: number) {
|
function fmtBytes(bytes: number) {
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowCounterClockwise, LockSimple, Warning } from "phosphor-svelte";
|
||||||
|
import { store, updateSettings, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
|
|
||||||
|
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 2, label: "2" },
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_KEEP_OPTIONS = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
{ value: 25, label: "25" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DELETE_DELAY_OPTIONS = [
|
||||||
|
{ value: 0, label: "Now" },
|
||||||
|
{ value: 24, label: "1 day" },
|
||||||
|
{ value: 168, label: "1 week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_OPTIONS = [
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "manual", label: "Manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type GlobalDefaults = Omit<MangaPrefs, "refreshInterval"> & {
|
||||||
|
refreshInterval: "daily" | "weekly" | "manual";
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallback: GlobalDefaults = {
|
||||||
|
autoDownload: false,
|
||||||
|
downloadAhead: 0,
|
||||||
|
maxKeepChapters: 0,
|
||||||
|
deleteOnRead: false,
|
||||||
|
deleteDelayHours: 0,
|
||||||
|
pauseUpdates: false,
|
||||||
|
refreshInterval: "weekly",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
|
||||||
|
return (store.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
|
||||||
|
updateSettings({
|
||||||
|
automationDefaults: {
|
||||||
|
...(store.settings.automationDefaults ?? fallback),
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enforceGlobal = $derived(store.settings.automationEnforceGlobal ?? false);
|
||||||
|
|
||||||
|
function toggleEnforce() {
|
||||||
|
updateSettings({ automationEnforceGlobal: !enforceGlobal });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customCount = $derived(
|
||||||
|
Object.keys(store.mangaPrefs ?? {}).filter((id) => {
|
||||||
|
const prefs = (store.mangaPrefs as Record<string, Partial<MangaPrefs>>)[id];
|
||||||
|
return prefs && Object.keys(prefs).length > 0;
|
||||||
|
}).length
|
||||||
|
);
|
||||||
|
|
||||||
|
let confirmReset = $state(false);
|
||||||
|
|
||||||
|
function resetAllCustoms() {
|
||||||
|
if (!confirmReset) { confirmReset = true; return; }
|
||||||
|
const ids = Object.keys(store.mangaPrefs ?? {});
|
||||||
|
const blank = { ...DEFAULT_MANGA_PREFS };
|
||||||
|
for (const id of ids) {
|
||||||
|
for (const key of Object.keys(blank) as (keyof MangaPrefs)[]) {
|
||||||
|
// setPref(Number(id), key, blank[key] as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSettings({ _resetMangaPrefs: Date.now() } as any);
|
||||||
|
confirmReset = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReset() { confirmReset = false; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="s-panel">
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Behaviour</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Enable automation</span>
|
||||||
|
<span class="s-desc">Allow per-series and global automation rules to run</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.automationEnabled ?? false}
|
||||||
|
aria-label="Enable automation"
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={store.settings.automationEnabled ?? false}
|
||||||
|
onclick={() => updateSettings({ automationEnabled: !(store.settings.automationEnabled ?? false) })}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Enforce global defaults</span>
|
||||||
|
<span class="s-desc">Ignore per-series overrides — all series use the global settings below</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enforceGlobal}
|
||||||
|
aria-label="Enforce global defaults"
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={enforceGlobal}
|
||||||
|
onclick={toggleEnforce}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if enforceGlobal}
|
||||||
|
<div class="s-banner s-banner-info enforce-banner">
|
||||||
|
<LockSimple size={12} weight="fill" />
|
||||||
|
<span>Per-series overrides are paused. Disable enforce to allow custom rules.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Global Defaults</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
|
||||||
|
<p class="sub-head">Downloads</p>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Auto-download new chapters</span>
|
||||||
|
<span class="s-desc">Queue new chapters when a series refreshes</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={getGlobal("autoDownload")}
|
||||||
|
aria-label="Auto-download new chapters"
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={getGlobal("autoDownload")}
|
||||||
|
onclick={() => setGlobal("autoDownload", !getGlobal("autoDownload"))}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row chip-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Download ahead</span>
|
||||||
|
<span class="s-desc">Pre-fetch chapters while reading</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="s-preset"
|
||||||
|
class:active={getGlobal("downloadAhead") === opt.value}
|
||||||
|
onclick={() => setGlobal("downloadAhead", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row chip-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Max chapters to keep</span>
|
||||||
|
<span class="s-desc">Delete oldest downloads when limit is exceeded</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
{#each MAX_KEEP_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="s-preset"
|
||||||
|
class:active={getGlobal("maxKeepChapters") === opt.value}
|
||||||
|
onclick={() => setGlobal("maxKeepChapters", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="sub-head sub-head-rule">On Read</p>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Delete after reading</span>
|
||||||
|
<span class="s-desc">Remove download when a chapter is marked read</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={getGlobal("deleteOnRead")}
|
||||||
|
aria-label="Delete after reading"
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={getGlobal("deleteOnRead")}
|
||||||
|
onclick={() => setGlobal("deleteOnRead", !getGlobal("deleteOnRead"))}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if getGlobal("deleteOnRead")}
|
||||||
|
<div class="s-row chip-row sub-row">
|
||||||
|
<span class="s-label">Delete delay</span>
|
||||||
|
<div class="chip-group">
|
||||||
|
{#each DELETE_DELAY_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="s-preset"
|
||||||
|
class:active={getGlobal("deleteDelayHours") === opt.value}
|
||||||
|
onclick={() => setGlobal("deleteDelayHours", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="sub-head sub-head-rule">Updates</p>
|
||||||
|
|
||||||
|
<div class="s-row chip-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Default refresh interval</span>
|
||||||
|
<span class="s-desc">How often series check for new chapters by default</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="s-preset"
|
||||||
|
class:active={getGlobal("refreshInterval") === opt.value}
|
||||||
|
onclick={() => setGlobal("refreshInterval", opt.value as GlobalDefaults["refreshInterval"])}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Custom Overrides</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Series with custom rules</span>
|
||||||
|
<span class="s-desc">Per-series settings set via the series automation panel</span>
|
||||||
|
</div>
|
||||||
|
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Reset all custom rules</span>
|
||||||
|
<span class="s-desc">Revert every series to the global defaults above</span>
|
||||||
|
</div>
|
||||||
|
{#if confirmReset}
|
||||||
|
<div class="s-btn-row">
|
||||||
|
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}>
|
||||||
|
<Warning size={11} weight="fill" /> Confirm reset
|
||||||
|
</button>
|
||||||
|
<button class="s-btn" onclick={cancelReset}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}>
|
||||||
|
<ArrowCounterClockwise size={11} weight="regular" /> Reset
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.enforce-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-head {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-widest);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-2) var(--sp-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-head-rule {
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row {
|
||||||
|
padding-left: calc(var(--sp-4) + var(--sp-2));
|
||||||
|
border-left: 2px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash, ArrowsClockwise, DownloadSimple } from "phosphor-svelte";
|
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_CATEGORIES } from "@api/queries/manga";
|
import { GET_CATEGORIES } from "@api/queries/manga";
|
||||||
import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
|
import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte";
|
import { store, updateSettings, setCategories } from "@store/state.svelte";
|
||||||
|
|
||||||
|
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
||||||
|
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
||||||
|
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
||||||
|
const orderedCatIds = $derived.by(() => {
|
||||||
|
const order = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
|
const known = new Set(sortedCatIds);
|
||||||
|
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
|
||||||
|
});
|
||||||
|
|
||||||
let catsLoading = $state(false);
|
let catsLoading = $state(false);
|
||||||
let catsError = $state<string | null>(null);
|
let catsError = $state<string | null>(null);
|
||||||
@@ -12,13 +21,26 @@
|
|||||||
let editingId = $state<number | null>(null);
|
let editingId = $state<number | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
|
|
||||||
|
let dragId = $state<number | null>(null);
|
||||||
|
let dragOverId = $state<number | null>(null);
|
||||||
|
let dropPosition = $state<"above" | "below" | null>(null);
|
||||||
|
|
||||||
|
function isHidden(id: string) {
|
||||||
|
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(id: string) {
|
||||||
|
const current = store.settings.hiddenLibraryTabs ?? [];
|
||||||
|
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
catsLoading = true; catsError = null;
|
catsLoading = true; catsError = null;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||||
const fresh = res.categories.nodes.filter(c => c.id !== 0);
|
const fresh = res.categories.nodes.filter(c => c.id !== 0);
|
||||||
const merged = fresh.map(f => {
|
const merged = fresh.map(f => {
|
||||||
const existing = store.categories.find(c => c.id === f.id);
|
const existing = store.categories.find(c => c.id === f.id);
|
||||||
return existing ? { ...existing, ...f } : f;
|
return existing ? { ...existing, ...f } : f;
|
||||||
});
|
});
|
||||||
@@ -63,26 +85,29 @@
|
|||||||
const next = !cat[flag];
|
const next = !cat[flag];
|
||||||
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: next } : c));
|
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: next } : c));
|
||||||
try {
|
try {
|
||||||
await gql(UPDATE_CATEGORIES, { ids: [id], patch: { [flag]: next } });
|
await gql(UPDATE_CATEGORIES, { ids: [id], patch: { [flag]: next ? "INCLUDE" : "EXCLUDE" } });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: !next } : c));
|
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: !next } : c));
|
||||||
catsError = e?.message ?? "Failed to update folder";
|
catsError = e?.message ?? "Failed to update folder";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveCategory(id: number, direction: -1 | 1) {
|
async function applyReorder(fromId: number, toId: number) {
|
||||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||||
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
||||||
const idx = sortable.findIndex(c => c.id === id);
|
const fromIdx = sortable.findIndex(c => c.id === fromId);
|
||||||
if (idx < 0) return;
|
const toIdx = sortable.findIndex(c => c.id === toId);
|
||||||
const newPos = idx + 1 + direction;
|
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
||||||
if (newPos < 1 || newPos > sortable.length) return;
|
|
||||||
const reordered = [...sortable];
|
const reordered = [...sortable];
|
||||||
const [moved] = reordered.splice(idx, 1);
|
const [moved] = reordered.splice(fromIdx, 1);
|
||||||
reordered.splice(idx + direction, 0, moved);
|
reordered.splice(toIdx, 0, moved);
|
||||||
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||||
|
|
||||||
|
const catIds = reordered.map(c => String(c.id));
|
||||||
|
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
|
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
|
||||||
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||||
setCategories([
|
setCategories([
|
||||||
...zeroCat,
|
...zeroCat,
|
||||||
@@ -97,6 +122,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, id: number) {
|
||||||
|
dragId = id;
|
||||||
|
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, id: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragId === id) return;
|
||||||
|
dragOverId = id;
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent, id: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragId !== null && dragId !== id) applyReorder(dragId, id);
|
||||||
|
dragId = null; dragOverId = null; dropPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; }
|
||||||
|
|
||||||
function focusInput(node: HTMLElement) { node.focus(); }
|
function focusInput(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -105,16 +152,121 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Manage Folders</p>
|
<p class="s-section-title">Manage Folders</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if catsError}
|
{#if catsError}
|
||||||
<div class="s-banner s-banner-error">{catsError}</div>
|
<div class="s-banner s-banner-error">{catsError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if catsLoading}
|
||||||
|
<p class="s-empty">Loading folders…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">Saved</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">Downloaded</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if completedCat}
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
|
||||||
|
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="s-folder-divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
||||||
|
{#each orderedCatIds.filter(id => id !== completedId) as id}
|
||||||
|
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
|
||||||
|
{@const hidden = isHidden(id)}
|
||||||
|
{#if cat}
|
||||||
|
<div
|
||||||
|
class="s-folder-row"
|
||||||
|
class:dragging={dragId === cat.id}
|
||||||
|
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
||||||
|
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
||||||
|
ondragover={(e) => onDragOver(e, cat.id)}
|
||||||
|
ondrop={(e) => onDrop(e, cat.id)}
|
||||||
|
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
||||||
|
>
|
||||||
|
{#if editingId === cat.id}
|
||||||
|
<input class="s-input full" bind:value={editingName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||||
|
onblur={commitEdit} use:focusInput />
|
||||||
|
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||||
|
{:else}
|
||||||
|
<div class="s-folder-identity" draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, cat.id)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<span class="s-folder-icon">
|
||||||
|
<FolderSimple size={14} weight="light" />
|
||||||
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
||||||
|
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
||||||
|
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||||
|
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||||
|
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||||
|
<Trash size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.categories.filter(c => c.id !== 0 && c.name !== "Completed").length === 0}
|
||||||
|
<p class="s-empty">No custom folders yet. Create one below.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="s-folder-create">
|
<div class="s-folder-create">
|
||||||
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
||||||
onkeydown={(e) => e.key === "Enter" && createFolder()} />
|
onkeydown={(e) => e.key === "Enter" && createFolder()} />
|
||||||
@@ -122,64 +274,163 @@
|
|||||||
<Plus size={13} weight="bold" /> Create
|
<Plus size={13} weight="bold" /> Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if catsLoading}
|
|
||||||
<p class="s-empty">Loading folders…</p>
|
|
||||||
{:else if store.categories.filter(c => c.id !== 0).length === 0}
|
|
||||||
<p class="s-empty">No folders yet. Create one above.</p>
|
|
||||||
{:else}
|
|
||||||
{@const displayCats = store.categories
|
|
||||||
.filter(c => c.id !== 0)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
|
||||||
if (a.id === defaultId) return -1;
|
|
||||||
if (b.id === defaultId) return 1;
|
|
||||||
return a.order - b.order;
|
|
||||||
})}
|
|
||||||
{#each displayCats as cat, i}
|
|
||||||
<div class="s-folder-row">
|
|
||||||
{#if editingId === cat.id}
|
|
||||||
<input class="s-input full" bind:value={editingName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
|
||||||
onblur={commitEdit} use:focusInput />
|
|
||||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
|
||||||
{:else}
|
|
||||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<span class="s-folder-name">{cat.name}</span>
|
|
||||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
|
||||||
<button class="s-btn-icon"
|
|
||||||
class:accent={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
|
||||||
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
|
|
||||||
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
|
||||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon"
|
|
||||||
onclick={() => toggleHiddenCategory(cat.id)}
|
|
||||||
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}>
|
|
||||||
{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="s-btn-icon"
|
|
||||||
class:accent={cat.includeInUpdate !== false}
|
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
|
|
||||||
title={cat.includeInUpdate !== false ? "Exclude from library updates" : "Include in library updates"}>
|
|
||||||
<ArrowsClockwise size={13} weight={cat.includeInUpdate !== false ? "bold" : "light"} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="s-btn-icon"
|
|
||||||
class:accent={cat.includeInDownload !== false}
|
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
|
|
||||||
title={cat.includeInDownload !== false ? "Exclude from auto-downloads" : "Include in auto-downloads"}>
|
|
||||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
|
||||||
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down">↓</button>
|
|
||||||
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
|
||||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.s-folder-list {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-list.is-dragging,
|
||||||
|
.s-folder-list.is-dragging * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row {
|
||||||
|
transition: opacity 0.15s, background 0.1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row.dragging {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row.drop-above::before,
|
||||||
|
.s-folder-row.drop-below::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-success, #4ade80);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row.drop-above::before { top: -1px; }
|
||||||
|
.s-folder-row.drop-below::after { bottom: -1px; }
|
||||||
|
|
||||||
|
.s-folder-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-icon-static {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-icon {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-icon > :global(*) {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-icon > :global(*:last-child) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row:hover .s-folder-icon > :global(*:first-child) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-row:hover .s-folder-icon > :global(*:last-child) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-name {
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-name-static {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-name-static:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon.active {
|
||||||
|
color: var(--accent, #6c8ef5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon.inactive {
|
||||||
|
color: var(--color-error, #f87171);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon.inactive:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon.muted {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon-lock {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon-lock:hover {
|
||||||
|
opacity: 0.25;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Render Limit</p>
|
<p class="s-section-title">Render Limit</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-slider-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">Items per page</span>
|
<span class="s-label">Items per page</span>
|
||||||
<span class="s-desc">Lower = faster on large libraries</span>
|
<span class="s-desc">Lower = faster on large libraries</span>
|
||||||
@@ -95,16 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-section">
|
|
||||||
<p class="s-section-title">Interface</p>
|
|
||||||
<div class="s-section-body">
|
|
||||||
<label class="s-row">
|
|
||||||
<div class="s-row-info"><span class="s-label">Compact sidebar</span><span class="s-desc">Collapses the sidebar to icons only</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="s-toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="s-toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Session Cache</p>
|
<p class="s-section-title">Session Cache</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
|
|||||||
@@ -159,7 +159,6 @@
|
|||||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
||||||
let advStorageOpen = $state(false);
|
let advStorageOpen = $state(false);
|
||||||
let backupSectionOpen = $state(false);
|
let backupSectionOpen = $state(false);
|
||||||
let appDataSectionOpen = $state(false);
|
|
||||||
let resetSectionOpen = $state(false);
|
let resetSectionOpen = $state(false);
|
||||||
|
|
||||||
async function fetchStorage() {
|
async function fetchStorage() {
|
||||||
@@ -661,6 +660,9 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if backupSectionOpen}
|
{#if backupSectionOpen}
|
||||||
<div class="s-collapsible-body">
|
<div class="s-collapsible-body">
|
||||||
|
|
||||||
|
<p class="s-subsection-title">Library backup</p>
|
||||||
|
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">Create backup</span>
|
<span class="s-label">Create backup</span>
|
||||||
@@ -768,17 +770,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="s-section">
|
<p class="s-subsection-title">App data backup</p>
|
||||||
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
|
|
||||||
<span class="s-label">App-Data Backup</span>
|
|
||||||
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if appDataSectionOpen}
|
|
||||||
<div class="s-collapsible-body">
|
|
||||||
|
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
|
|||||||
@@ -16,10 +16,12 @@
|
|||||||
let oauthTrackerId = $state<number | null>(null);
|
let oauthTrackerId = $state<number | null>(null);
|
||||||
let oauthCallbackInput = $state("");
|
let oauthCallbackInput = $state("");
|
||||||
let oauthSubmitting = $state(false);
|
let oauthSubmitting = $state(false);
|
||||||
|
let oauthError = $state<string | null>(null);
|
||||||
let credsTrackerId = $state<number | null>(null);
|
let credsTrackerId = $state<number | null>(null);
|
||||||
let credsUsername = $state("");
|
let credsUsername = $state("");
|
||||||
let credsPassword = $state("");
|
let credsPassword = $state("");
|
||||||
let credsSubmitting = $state(false);
|
let credsSubmitting = $state(false);
|
||||||
|
let credsError = $state<string | null>(null);
|
||||||
let loggingOut = $state<number | null>(null);
|
let loggingOut = $state<number | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -50,11 +52,11 @@
|
|||||||
await loadTrackers();
|
await loadTrackers();
|
||||||
oauthTrackerId = null; oauthCallbackInput = "";
|
oauthTrackerId = null; oauthCallbackInput = "";
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
trackersError = e?.message ?? "Login failed";
|
oauthError = e?.message ?? "Login failed";
|
||||||
} finally { oauthSubmitting = false; }
|
} finally { oauthSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; }
|
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
|
||||||
|
|
||||||
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
|
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
|
||||||
|
|
||||||
@@ -66,11 +68,11 @@
|
|||||||
await loadTrackers();
|
await loadTrackers();
|
||||||
credsTrackerId = null; credsUsername = ""; credsPassword = "";
|
credsTrackerId = null; credsUsername = ""; credsPassword = "";
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
trackersError = e?.message ?? "Login failed";
|
credsError = e?.message ?? "Login failed";
|
||||||
} finally { credsSubmitting = false; }
|
} finally { credsSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; }
|
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
|
||||||
|
|
||||||
async function logoutTracker(trackerId: number) {
|
async function logoutTracker(trackerId: number) {
|
||||||
loggingOut = trackerId;
|
loggingOut = trackerId;
|
||||||
@@ -127,7 +129,7 @@
|
|||||||
<p class="s-section-title">Connected Trackers</p>
|
<p class="s-section-title">Connected Trackers</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
{#if trackersError}
|
{#if trackersError}
|
||||||
<div class="s-banner s-banner-error">{trackersError}</div>
|
<div class="s-banner s-banner-error" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if trackersLoading}
|
{#if trackersLoading}
|
||||||
<p class="s-empty">Loading trackers…</p>
|
<p class="s-empty">Loading trackers…</p>
|
||||||
@@ -168,6 +170,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if oauthTrackerId === tracker.id}
|
{#if oauthTrackerId === tracker.id}
|
||||||
<div class="s-tracker-expand">
|
<div class="s-tracker-expand">
|
||||||
|
{#if oauthError}
|
||||||
|
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
|
||||||
|
{/if}
|
||||||
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
|
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
|
||||||
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
|
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
|
||||||
bind:value={oauthCallbackInput}
|
bind:value={oauthCallbackInput}
|
||||||
@@ -183,6 +188,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if credsTrackerId === tracker.id}
|
{#if credsTrackerId === tracker.id}
|
||||||
<div class="s-tracker-expand">
|
<div class="s-tracker-expand">
|
||||||
|
{#if credsError}
|
||||||
|
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
|
||||||
|
{/if}
|
||||||
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
|
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
|
||||||
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
|
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
|
||||||
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
|
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
|
||||||
@@ -266,4 +274,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||||
|
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||||
|
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||||
</style>
|
</style>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
function openManga() {
|
function openManga() {
|
||||||
if (!record.manga) return;
|
if (!record.manga) return;
|
||||||
setActiveManga(record.manga as any);
|
setActiveManga(record.manga as any);
|
||||||
setNavPage("library");
|
setNavPage(store.navPage);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,32 +32,37 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-top">
|
<div class="toolbar-top">
|
||||||
<span class="heading">Tracking</span>
|
<span class="heading">Tracking</span>
|
||||||
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
|
|
||||||
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
|
{#if !loading && loggedIn.length > 0}
|
||||||
</button>
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab" class:active={activeTrackerId === "all"}
|
||||||
|
onclick={() => onTrackerChange("all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span class="tab-count">{totalCount}</span>
|
||||||
|
</button>
|
||||||
|
{#each loggedIn as t}
|
||||||
|
<button
|
||||||
|
class="tab" class:active={activeTrackerId === t.id}
|
||||||
|
onclick={() => onTrackerChange(t.id)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||||
|
{t.name}
|
||||||
|
<span class="tab-count">{t.trackRecords.nodes.length}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
|
||||||
|
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !loading && loggedIn.length > 0}
|
{#if !loading && loggedIn.length > 0}
|
||||||
<div class="tracker-tabs">
|
|
||||||
<button
|
|
||||||
class="tracker-tab" class:active={activeTrackerId === "all"}
|
|
||||||
onclick={() => onTrackerChange("all")}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
<span class="tab-count">{totalCount}</span>
|
|
||||||
</button>
|
|
||||||
{#each loggedIn as t}
|
|
||||||
<button
|
|
||||||
class="tracker-tab" class:active={activeTrackerId === t.id}
|
|
||||||
onclick={() => onTrackerChange(t.id)}
|
|
||||||
>
|
|
||||||
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
|
||||||
{t.name}
|
|
||||||
<span class="tab-count">{t.trackRecords.nodes.length}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={13} weight="light" class="search-ico" />
|
<MagnifyingGlass size={13} weight="light" class="search-ico" />
|
||||||
@@ -94,79 +99,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.toolbar { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
.toolbar { flex-shrink: 0; }
|
||||||
|
|
||||||
.toolbar-top {
|
.toolbar-top {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; gap: var(--sp-4);
|
||||||
padding: var(--sp-4) var(--sp-6);
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
}
|
}
|
||||||
.heading {
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.icon-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.tracker-tabs {
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||||
display: flex; align-items: center; gap: 1px;
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
|
||||||
}
|
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
.tracker-tab {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 8px 10px 7px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
color: var(--text-faint); background: none; border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.tracker-tab:hover { color: var(--text-muted); }
|
|
||||||
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
|
||||||
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
|
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
|
||||||
|
|
||||||
.tab-count {
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full);
|
.tab.active .tab-count { opacity: 1; }
|
||||||
background: var(--bg-overlay); color: var(--text-faint); line-height: 15px;
|
|
||||||
}
|
|
||||||
.tracker-tab.active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.filter-row {
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
padding: var(--sp-2) var(--sp-5) var(--sp-3);
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
}
|
|
||||||
.search-wrap {
|
.filter-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); }
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
.search-wrap { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; transition: border-color var(--t-base); }
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 10px;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search-wrap:focus-within { border-color: var(--border-strong); }
|
.search-wrap:focus-within { border-color: var(--border-strong); }
|
||||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.search-input {
|
.search-input { flex: 1; background: none; border: none; outline: none; min-width: 0; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
|
||||||
font-size: var(--text-sm); color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.search-input::placeholder { color: var(--text-faint); }
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
.pill-select {
|
.pill-select { flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 22px 5px 9px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); outline: none; cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), color var(--t-base); }
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 22px 5px 9px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
|
||||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 7px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
</style>
|
</style>
|
||||||
@@ -134,6 +134,21 @@ export async function syncBackFromTracker(
|
|||||||
scanlatorForce: false,
|
scanlatorForce: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
const seenInt = new Map<number, Chapter>();
|
||||||
|
for (const ch of eligible) {
|
||||||
|
const key = Math.floor(ch.chapterNumber);
|
||||||
|
if (!Number.isInteger(ch.chapterNumber)) continue;
|
||||||
|
if (!seenInt.has(key)) seenInt.set(key, ch);
|
||||||
|
}
|
||||||
|
const dedupedEligible = [...seenInt.values()];
|
||||||
|
const decimalsByFloor = new Map<number, Chapter[]>();
|
||||||
|
for (const ch of eligible) {
|
||||||
|
if (Number.isInteger(ch.chapterNumber)) continue;
|
||||||
|
const key = Math.floor(ch.chapterNumber);
|
||||||
|
const arr = decimalsByFloor.get(key) ?? [];
|
||||||
|
arr.push(ch);
|
||||||
|
decimalsByFloor.set(key, arr);
|
||||||
|
}
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
const toMarkRead: number[] = [];
|
||||||
|
|
||||||
@@ -141,11 +156,14 @@ export async function syncBackFromTracker(
|
|||||||
const remote = record.lastChapterRead;
|
const remote = record.lastChapterRead;
|
||||||
if (!remote || remote <= 0) continue;
|
if (!remote || remote <= 0) continue;
|
||||||
|
|
||||||
for (const chapter of eligible) {
|
for (const chapter of dedupedEligible) {
|
||||||
if (chapter.isRead) continue;
|
if (chapter.isRead) continue;
|
||||||
const diff = Math.abs(chapter.chapterNumber - remote);
|
if (chapter.chapterNumber > remote) continue;
|
||||||
if (opts.threshold !== null && diff > opts.threshold) continue;
|
if (opts.threshold !== null && remote - chapter.chapterNumber > opts.threshold) continue;
|
||||||
if (chapter.chapterNumber <= remote) toMarkRead.push(chapter.id);
|
toMarkRead.push(chapter.id);
|
||||||
|
for (const dec of decimalsByFloor.get(chapter.chapterNumber) ?? []) {
|
||||||
|
if (!dec.isRead) toMarkRead.push(dec.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { store, clearHistory, setPreviewManga } from "@store/state.svelte";
|
import { store, clearHistory, setPreviewManga } from "@store/state.svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
||||||
|
|
||||||
|
let libraryManga = $state<Manga[]>([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
|
)
|
||||||
|
.then(m => { libraryManga = m; })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function thumbFor(mangaId: number, fallback: string): string {
|
||||||
|
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
let confirmClear = $state(false);
|
let confirmClear = $state(false);
|
||||||
|
|
||||||
@@ -173,9 +192,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
{#each items as session (session.latestChapterId)}
|
{#each items as session (session.latestChapterId)}
|
||||||
<button class="session-row" onclick={() => setPreviewManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any)}>
|
<button class="session-row" onclick={() => setPreviewManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: thumbFor(session.mangaId, session.thumbnailUrl) } as any)}>
|
||||||
<div class="thumb-wrap">
|
<div class="thumb-wrap">
|
||||||
<Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
|
<Thumbnail src={thumbFor(session.mangaId, session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
||||||
{#if session.chapterCount > 1}
|
{#if session.chapterCount > 1}
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
<span class="session-count">{session.chapterCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -215,7 +234,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--sp-3) var(--sp-5);
|
padding: var(--sp-4) var(--sp-6);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -292,33 +311,16 @@
|
|||||||
.search-clear:hover { color: var(--text-muted); }
|
.search-clear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 4px;
|
||||||
align-items: center;
|
height: 30px; padding: 0 var(--sp-2);
|
||||||
gap: 4px;
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
height: 26px;
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
padding: 0 var(--sp-2);
|
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
border-radius: var(--radius-md);
|
letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||||
}
|
}
|
||||||
|
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
.clear-btn:hover {
|
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
color: var(--color-error);
|
|
||||||
background: var(--color-error-bg);
|
|
||||||
border-color: color-mix(in srgb, var(--color-error) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn.confirm {
|
|
||||||
color: var(--color-error);
|
|
||||||
background: var(--color-error-bg);
|
|
||||||
border-color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
.clear-label { font-size: var(--text-2xs); }
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
store.activeManga = null;
|
store.activeManga = null;
|
||||||
store.activeSource = null;
|
store.activeSource = null;
|
||||||
store.genreFilter = "";
|
store.genreFilter = "";
|
||||||
|
store.searchQuery = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
store.activeManga = null;
|
store.activeManga = null;
|
||||||
store.libraryFilter = "library";
|
store.libraryFilter = "library";
|
||||||
store.genreFilter = "";
|
store.genreFilter = "";
|
||||||
|
store.searchQuery = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: transparent; display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; }
|
.root { width: var(--sidebar-width); flex-shrink: 0; background: transparent; display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; border-right: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
.logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); padding: 0; appearance: none; -webkit-appearance: none; }
|
.logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||||
.logo:hover { opacity: 0.8; }
|
.logo:hover { opacity: 0.8; }
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
const leaving = new Set<string>();
|
const leaving = new Set<string>();
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
let detail = $state<Toast | null>(null);
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
function schedule(t: Toast) {
|
||||||
if (timers.has(t.id)) return;
|
if (timers.has(t.id)) return;
|
||||||
const dur = t.duration ?? 3500;
|
const dur = t.duration ?? 3500;
|
||||||
@@ -30,12 +32,23 @@
|
|||||||
dismissToast(id);
|
dismissToast(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDetail(e: MouseEvent, t: Toast) {
|
||||||
|
e.preventDefault();
|
||||||
|
detail = t;
|
||||||
|
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
detail = null;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const activeIds = new Set(store.toasts.map(t => t.id));
|
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||||
store.toasts.forEach(schedule);
|
store.toasts.forEach(schedule);
|
||||||
for (const [id, timer] of timers) {
|
for (const [id, timer] of timers) {
|
||||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||||
}
|
}
|
||||||
|
if (detail && !activeIds.has(detail.id)) detail = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
const icons: Record<Toast["kind"], string> = {
|
||||||
@@ -49,7 +62,10 @@
|
|||||||
{#if store.toasts.length}
|
{#if store.toasts.length}
|
||||||
<div class="toaster" aria-live="polite">
|
<div class="toaster" aria-live="polite">
|
||||||
{#each store.toasts as t (t.id)}
|
{#each store.toasts as t (t.id)}
|
||||||
<button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}" onclick={() => dismiss(t.id)}>
|
<button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}"
|
||||||
|
onclick={() => dismiss(t.id)}
|
||||||
|
oncontextmenu={(e) => openDetail(e, t)}
|
||||||
|
>
|
||||||
<div class="accent-bar"></div>
|
<div class="accent-bar"></div>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -65,6 +81,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if detail}
|
||||||
|
<div class="detail-backdrop" role="presentation" onclick={closeDetail} oncontextmenu={(e) => e.preventDefault()}>
|
||||||
|
<div class="detail-panel detail-{detail.kind}" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="detail-accent"></div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-kind">{detail.kind}</span>
|
||||||
|
<button class="detail-close" onclick={closeDetail} aria-label="Close">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="detail-title">{detail.title}</p>
|
||||||
|
{#if detail.body}
|
||||||
|
<pre class="detail-text">{detail.body}</pre>
|
||||||
|
{/if}
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.title}${detail!.body ? '\n' + detail!.body : ''}`)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); closeDetail(); }}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; }
|
.toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; }
|
||||||
|
|
||||||
@@ -105,4 +151,79 @@
|
|||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||||
.title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.detail-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 10000;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.15s ease both;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
display: flex; width: 420px; max-width: calc(100vw - 32px); max-height: 60vh;
|
||||||
|
border-radius: var(--radius-lg); background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: popIn 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
}
|
||||||
|
@keyframes popIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
|
||||||
|
.detail-accent { width: 3px; flex-shrink: 0; }
|
||||||
|
.detail-error .detail-accent { background: var(--color-error); }
|
||||||
|
.detail-success .detail-accent { background: var(--accent-fg); }
|
||||||
|
.detail-info .detail-accent { background: var(--text-faint); }
|
||||||
|
.detail-download .detail-accent { background: var(--accent-fg); }
|
||||||
|
|
||||||
|
.detail-body { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: var(--sp-3); gap: var(--sp-2); overflow: hidden; }
|
||||||
|
|
||||||
|
.detail-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.detail-kind {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.detail-error .detail-kind { color: var(--color-error); }
|
||||||
|
|
||||||
|
.detail-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||||||
|
background: none; border: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.detail-close:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary); font-weight: var(--weight-medium);
|
||||||
|
line-height: var(--leading-snug); word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-text {
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto;
|
||||||
|
font-family: var(--font-mono, monospace); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); line-height: var(--leading-relaxed);
|
||||||
|
white-space: pre-wrap; word-break: break-all;
|
||||||
|
background: var(--bg-void); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||||
|
.detail-copy, .detail-dismiss {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px var(--sp-3); border-radius: var(--radius-sm); cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.detail-copy {
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.detail-copy:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
.detail-dismiss {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.detail-dismiss:hover { background: color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||||
</style>
|
</style>
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
let loadingLinkList = $state(false);
|
let loadingLinkList = $state(false);
|
||||||
let coverPickerOpen = $state(false);
|
let coverPickerOpen = $state(false);
|
||||||
|
|
||||||
|
let originNavPage = store.navPage;
|
||||||
|
|
||||||
const linkedIds = $derived(
|
const linkedIds = $derived(
|
||||||
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
||||||
);
|
);
|
||||||
@@ -152,6 +154,7 @@
|
|||||||
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
||||||
const focal = store.previewManga;
|
const focal = store.previewManga;
|
||||||
if (focal) {
|
if (focal) {
|
||||||
|
originNavPage = store.navPage;
|
||||||
load(focal.id);
|
load(focal.id);
|
||||||
loadCategories(focal.id);
|
loadCategories(focal.id);
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
@@ -256,7 +259,7 @@
|
|||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
setActiveManga(displayManga);
|
setActiveManga(displayManga);
|
||||||
setNavPage("library");
|
setNavPage(originNavPage);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { thumbUrl, plainThumbUrl } from "@api/client";
|
import { plainThumbUrl, getServerUrl } from "@api/client";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH");
|
const isAuth = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
|
|
||||||
let blobUrl = $state("");
|
let blobUrl = $state("");
|
||||||
let reqId = 0;
|
let reqId = 0;
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
||||||
|
|
||||||
const id = ++reqId;
|
const id = ++reqId;
|
||||||
getBlobUrl(plainThumbUrl(_src), _priority)
|
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
||||||
|
getBlobUrl(bareUrl, _priority)
|
||||||
.then(u => { if (id === reqId) blobUrl = u; })
|
.then(u => { if (id === reqId) blobUrl = u; })
|
||||||
.catch(() => { if (id === reqId) blobUrl = ""; });
|
.catch(() => { if (id === reqId) blobUrl = ""; });
|
||||||
});
|
});
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
const resolved = $derived(
|
const resolved = $derived(
|
||||||
isAuth
|
isAuth
|
||||||
? (blobUrl || undefined)
|
? (blobUrl || undefined)
|
||||||
: (src ? thumbUrl(src) : undefined)
|
: (src ? plainThumbUrl(src) : undefined)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -3,20 +3,32 @@ export type NavPage =
|
|||||||
| "downloads" | "extensions" | "history" | "search" | "tracking";
|
| "downloads" | "extensions" | "history" | "search" | "tracking";
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
navPage: NavPage = $state("home");
|
navPage: NavPage = $state("home");
|
||||||
settingsOpen: boolean = $state(false);
|
settingsOpen: boolean = $state(false);
|
||||||
searchPrefill: string = $state("");
|
searchPrefill: string = $state("");
|
||||||
genreFilter: string = $state("");
|
searchQuery: string = $state("");
|
||||||
|
genreFilter: string = $state("");
|
||||||
|
scrollPositions: Map<string, number> = $state(new Map());
|
||||||
|
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||||
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||||
setGenreFilter(next: string) { this.genreFilter = next; }
|
setSearchQuery(next: string) { this.searchQuery = next; }
|
||||||
|
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||||
|
saveScroll(key: string, top: number) {
|
||||||
|
const m = new Map(this.scrollPositions);
|
||||||
|
m.set(key, top);
|
||||||
|
this.scrollPositions = m;
|
||||||
|
}
|
||||||
|
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = new AppStore();
|
export const app = new AppStore();
|
||||||
|
|
||||||
export function setNavPage(next: NavPage) { app.setNavPage(next); }
|
export function setNavPage(next: NavPage) { app.setNavPage(next); }
|
||||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
|
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
|
||||||
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
|
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
|
||||||
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
|
export function setSearchQuery(next: string) { app.setSearchQuery(next); }
|
||||||
|
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
|
||||||
|
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
|
||||||
|
export function getScroll(key: string): number { return app.getScroll(key); }
|
||||||
@@ -2,6 +2,7 @@ import { store } from "@store/state.svelte";
|
|||||||
import { probeServer, loginBasic, loginUI } from "@core/auth";
|
import { probeServer, loginBasic, loginUI } from "@core/auth";
|
||||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import { loadAllStores } from "@core/persistence/persist";
|
import { loadAllStores } from "@core/persistence/persist";
|
||||||
|
import { notifyReauthSuccess } from "@api/client";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40;
|
const MAX_ATTEMPTS = 40;
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
|
|||||||
boot.skipped = false;
|
boot.skipped = false;
|
||||||
boot.loginPass = "";
|
boot.loginPass = "";
|
||||||
boot.loginError = null;
|
boot.loginError = null;
|
||||||
|
notifyReauthSuccess();
|
||||||
trackingState.bootSync().catch(() => {});
|
trackingState.bootSync().catch(() => {});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
+22
-11
@@ -10,6 +10,7 @@ import { notifications } from "./no
|
|||||||
import { app } from "./app.svelte";
|
import { app } from "./app.svelte";
|
||||||
import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist";
|
import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist";
|
||||||
import type { PersistedData } from "../core/persistence/persist";
|
import type { PersistedData } from "../core/persistence/persist";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
|
||||||
function localDateStr(d: Date): string {
|
function localDateStr(d: Date): string {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
@@ -53,6 +54,8 @@ function mergeSettings(saved: any): Settings {
|
|||||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||||
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
|
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
|
||||||
|
hiddenLibraryTabs: saved?.settings?.hiddenLibraryTabs ?? [],
|
||||||
|
libraryPinnedTabOrder: saved?.settings?.libraryPinnedTabOrder ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +96,8 @@ class Store {
|
|||||||
set settingsOpen(v) { app.setSettingsOpen(v); }
|
set settingsOpen(v) { app.setSettingsOpen(v); }
|
||||||
get searchPrefill() { return app.searchPrefill; }
|
get searchPrefill() { return app.searchPrefill; }
|
||||||
set searchPrefill(v) { app.setSearchPrefill(v); }
|
set searchPrefill(v) { app.setSearchPrefill(v); }
|
||||||
|
get searchQuery() { return app.searchQuery; }
|
||||||
|
set searchQuery(v) { app.setSearchQuery(v); }
|
||||||
get genreFilter() { return app.genreFilter; }
|
get genreFilter() { return app.genreFilter; }
|
||||||
set genreFilter(v) { app.setGenreFilter(v); }
|
set genreFilter(v) { app.setGenreFilter(v); }
|
||||||
|
|
||||||
@@ -104,17 +109,23 @@ class Store {
|
|||||||
(saved.settings as any)[key] = (DEFAULT_SETTINGS as any)[key];
|
(saved.settings as any)[key] = (DEFAULT_SETTINGS as any)[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.settings = mergeSettings(saved);
|
// Assign all persisted values outside of reactive tracking so the
|
||||||
this.history = saved.history ?? [];
|
// $effects registered below don't fire on this initial write.
|
||||||
this.bookmarks = saved.bookmarks ?? [];
|
untrack(() => {
|
||||||
this.markers = saved.markers ?? [];
|
this.settings = mergeSettings(saved);
|
||||||
this.readLog = saved.readLog ?? [];
|
this.history = saved.history ?? [];
|
||||||
this.readingStats = saved.readingStats ?? { ...DEFAULT_READING_STATS };
|
this.bookmarks = saved.bookmarks ?? [];
|
||||||
this.dailyReadCounts = saved.dailyReadCounts ?? {};
|
this.markers = saved.markers ?? [];
|
||||||
this.libraryUpdates = saved.libraryUpdates ?? [];
|
this.readLog = saved.readLog ?? [];
|
||||||
this.lastLibraryRefresh = saved.lastLibraryRefresh ?? 0;
|
this.readingStats = saved.readingStats ?? { ...DEFAULT_READING_STATS };
|
||||||
this.acknowledgedUpdates = new Set(saved.acknowledgedUpdateIds ?? []);
|
this.dailyReadCounts = saved.dailyReadCounts ?? {};
|
||||||
|
this.libraryUpdates = saved.libraryUpdates ?? [];
|
||||||
|
this.lastLibraryRefresh = saved.lastLibraryRefresh ?? 0;
|
||||||
|
this.acknowledgedUpdates = new Set(saved.acknowledgedUpdateIds ?? []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark ready before registering effects so the first reactive run
|
||||||
|
// (which Svelte schedules after the current microtask) is allowed through.
|
||||||
this.#ready = true;
|
this.#ready = true;
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
@@ -394,4 +405,4 @@ export async function checkAndMarkCompleted(
|
|||||||
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
|
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
|
||||||
|
|
||||||
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
|
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
|
||||||
export { setNavPage, setSettingsOpen, setSearchPrefill, setGenreFilter } from "./app.svelte";
|
export { setNavPage, setSettingsOpen, setSearchPrefill, setSearchQuery, setGenreFilter, saveScroll, getScroll } from "./app.svelte";
|
||||||
@@ -124,6 +124,10 @@ export interface Settings {
|
|||||||
trackerRespectScanlatorFilter: boolean;
|
trackerRespectScanlatorFilter: boolean;
|
||||||
pinchZoom?: boolean;
|
pinchZoom?: boolean;
|
||||||
autoLinkOnOpen: boolean;
|
autoLinkOnOpen: boolean;
|
||||||
|
downloadToastsEnabled: boolean;
|
||||||
|
downloadAutoRetry: boolean;
|
||||||
|
hiddenLibraryTabs: string[];
|
||||||
|
libraryPinnedTabOrder: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -163,4 +167,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
trackerRespectScanlatorFilter: true,
|
trackerRespectScanlatorFilter: true,
|
||||||
pinchZoom: false,
|
pinchZoom: false,
|
||||||
autoLinkOnOpen: false,
|
autoLinkOnOpen: false,
|
||||||
|
downloadToastsEnabled: true,
|
||||||
|
downloadAutoRetry: false,
|
||||||
|
hiddenLibraryTabs: [],
|
||||||
|
libraryPinnedTabOrder: [],
|
||||||
};
|
};
|
||||||
+1
-1
@@ -27,7 +27,7 @@ export default defineConfig({
|
|||||||
envPrefix: ["VITE_", "TAURI_"],
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
build: {
|
build: {
|
||||||
target: ["es2021", "chrome100", "safari13"],
|
target: ["es2021", "chrome100", "safari13"],
|
||||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
minify: !process.env.TAURI_DEBUG ? "oxc" : false,
|
||||||
sourcemap: !!process.env.TAURI_DEBUG,
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user