mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Compare commits
16 Commits
0.9.2
...
7b2ae74c02
| Author | SHA1 | Date | |
|---|---|---|---|
| 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]
|
||||||
|
|||||||
@@ -77,9 +77,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();
|
||||||
}
|
}
|
||||||
+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
+19
-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;
|
||||||
|
|||||||
Vendored
+12
-9
@@ -1,5 +1,5 @@
|
|||||||
import { gql, plainThumbUrl } from "@api/client";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
import { getBlobUrl } 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";
|
||||||
|
|
||||||
@@ -11,8 +11,14 @@ 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 +35,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;
|
||||||
})
|
})
|
||||||
|
|||||||
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -179,7 +179,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 +188,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(); }
|
||||||
@@ -541,7 +534,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,7 +592,6 @@
|
|||||||
{tabFilters}
|
{tabFilters}
|
||||||
{hasActiveFilters}
|
{hasActiveFilters}
|
||||||
{anims}
|
{anims}
|
||||||
{tabIndicator}
|
|
||||||
{visibleCategories}
|
{visibleCategories}
|
||||||
{counts}
|
{counts}
|
||||||
{search}
|
{search}
|
||||||
|
|||||||
@@ -15,14 +15,9 @@
|
|||||||
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[];
|
||||||
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 +34,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,13 +44,13 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||||
anims, tabIndicator, visibleCategories, counts, search, refreshing,
|
anims, visibleCategories, counts, search, refreshing,
|
||||||
refreshProgress, refreshDone, refreshingCatId, activeDragKind, dragInsertIdx,
|
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();
|
||||||
|
|
||||||
@@ -85,9 +77,6 @@
|
|||||||
<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}>
|
||||||
{#if anims && tabIndicator.width > 0}
|
|
||||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
|
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
|
||||||
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
|
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
{#if f === "library"}<Books size={11} weight="bold" />
|
||||||
@@ -118,20 +107,6 @@
|
|||||||
<Folder size={11} weight="bold" />
|
<Folder size={11} weight="bold" />
|
||||||
{cat.name}
|
{cat.name}
|
||||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
<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>
|
</button>
|
||||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
@@ -150,10 +125,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 +204,16 @@
|
|||||||
.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: hidden; }
|
||||||
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
||||||
.tabs-scroll::-webkit-scrollbar { display: none; }
|
.tabs-scroll::-webkit-scrollbar { display: none; }
|
||||||
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
|
.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); 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 { 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 +222,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; }
|
||||||
|
|||||||
@@ -86,6 +86,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 +127,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +224,19 @@
|
|||||||
{#if style === "longstrip"}
|
{#if style === "longstrip"}
|
||||||
{#each stripToRender as chunk}
|
{#each stripToRender as chunk}
|
||||||
{#each chunk.urls as url, i}
|
{#each chunk.urls as url, i}
|
||||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
{#if i < 8}
|
||||||
<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" />
|
{#await resolveUrl(url, 8 - i)}
|
||||||
{:then src}
|
<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="eager" decoding="async" />
|
||||||
<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" />
|
{:then src}
|
||||||
{/await}
|
<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="eager" decoding="async" />
|
||||||
|
{/await}
|
||||||
|
{:else}
|
||||||
|
{#await resolveUrl(url, 0)}
|
||||||
|
<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="lazy" decoding="async" />
|
||||||
|
{:then src}
|
||||||
|
<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="lazy" decoding="async" />
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -142,12 +142,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}">
|
||||||
@@ -404,16 +410,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 +433,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 {
|
||||||
|
|||||||
@@ -682,6 +682,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 {
|
||||||
|
|||||||
@@ -60,8 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() { setSettingsOpen(false); }
|
function close() { setSettingsOpen(false); }
|
||||||
|
1
|
||||||
// Keybind capture
|
|
||||||
let listeningKey: keyof Keybinds | null = $state(null);
|
let listeningKey: keyof Keybinds | null = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -83,7 +82,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 +103,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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 } 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";
|
||||||
@@ -12,13 +12,17 @@
|
|||||||
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);
|
||||||
|
|
||||||
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 +67,25 @@
|
|||||||
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 }))]);
|
||||||
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 +100,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,7 +130,6 @@
|
|||||||
</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">
|
||||||
@@ -135,51 +159,196 @@
|
|||||||
if (b.id === defaultId) return 1;
|
if (b.id === defaultId) return 1;
|
||||||
return a.order - b.order;
|
return a.order - b.order;
|
||||||
})}
|
})}
|
||||||
{#each displayCats as cat, i}
|
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
||||||
<div class="s-folder-row">
|
{#each displayCats as cat}
|
||||||
{#if editingId === cat.id}
|
<div
|
||||||
<input class="s-input full" bind:value={editingName}
|
class="s-folder-row"
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
class:dragging={dragId === cat.id}
|
||||||
onblur={commitEdit} use:focusInput />
|
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
||||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
||||||
{:else}
|
ondragover={(e) => onDragOver(e, cat.id)}
|
||||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
ondrop={(e) => onDrop(e, cat.id)}
|
||||||
<span class="s-folder-name">{cat.name}</span>
|
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
||||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
>
|
||||||
<button class="s-btn-icon"
|
{#if editingId === cat.id}
|
||||||
class:accent={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
<input class="s-input full" bind:value={editingName}
|
||||||
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
|
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||||
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
onblur={commitEdit} use:focusInput />
|
||||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||||
</button>
|
{:else}
|
||||||
<button class="s-btn-icon"
|
{@const isDefault = (store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||||
onclick={() => toggleHiddenCategory(cat.id)}
|
{@const isHidden = (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}
|
||||||
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}>
|
{@const inUpdate = cat.includeInUpdate !== false}
|
||||||
{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
{@const inDl = cat.includeInDownload !== false}
|
||||||
</button>
|
|
||||||
<button
|
<div class="s-folder-identity" draggable="true"
|
||||||
class="s-btn-icon"
|
ondragstart={(e) => onDragStart(e, cat.id)}
|
||||||
class:accent={cat.includeInUpdate !== false}
|
ondragend={onDragEnd}>
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
|
<span class="s-folder-icon">
|
||||||
title={cat.includeInUpdate !== false ? "Exclude from library updates" : "Include in library updates"}>
|
<FolderSimple size={14} weight="light" />
|
||||||
<ArrowsClockwise size={13} weight={cat.includeInUpdate !== false ? "bold" : "light"} />
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
</button>
|
</span>
|
||||||
<button
|
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">
|
||||||
class="s-btn-icon"
|
{cat.name}
|
||||||
class:accent={cat.includeInDownload !== false}
|
</span>
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
|
</div>
|
||||||
title={cat.includeInDownload !== false ? "Exclude from auto-downloads" : "Include in auto-downloads"}>
|
|
||||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down">↓</button>
|
<button class="s-btn-icon" class:active={isDefault}
|
||||||
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
onclick={() => updateSettings({ defaultLibraryCategoryId: isDefault ? null : cat.id })}
|
||||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
title={isDefault ? "Remove as default folder" : "Set as default folder"}>
|
||||||
{/if}
|
<Star size={13} weight={isDefault ? "fill" : "light"} />
|
||||||
</div>
|
</button>
|
||||||
{/each}
|
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden}
|
||||||
|
onclick={() => toggleHiddenCategory(cat.id)}
|
||||||
|
title={isHidden ? "Show in library" : "Hide from library"}>
|
||||||
|
{#if isHidden}
|
||||||
|
<EyeSlash size={13} weight="light" />
|
||||||
|
{:else}
|
||||||
|
<Eye size={13} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="s-btn-icon" class:active={inUpdate} class:inactive={!inUpdate}
|
||||||
|
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
|
||||||
|
title={inUpdate ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||||
|
{#if inUpdate}
|
||||||
|
<ArrowsClockwise size={13} weight="bold" />
|
||||||
|
{:else}
|
||||||
|
<ArrowsCounterClockwise size={13} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="s-btn-icon" class:active={inDl} class:inactive={!inDl}
|
||||||
|
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
|
||||||
|
title={inDl ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||||
|
<DownloadSimple size={13} weight={inDl ? "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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/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-identity:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+17
-10
@@ -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")}`;
|
||||||
@@ -104,17 +105,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(() => {
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export interface Settings {
|
|||||||
trackerRespectScanlatorFilter: boolean;
|
trackerRespectScanlatorFilter: boolean;
|
||||||
pinchZoom?: boolean;
|
pinchZoom?: boolean;
|
||||||
autoLinkOnOpen: boolean;
|
autoLinkOnOpen: boolean;
|
||||||
|
downloadToastsEnabled: boolean;
|
||||||
|
downloadAutoRetry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -163,4 +165,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
trackerRespectScanlatorFilter: true,
|
trackerRespectScanlatorFilter: true,
|
||||||
pinchZoom: false,
|
pinchZoom: false,
|
||||||
autoLinkOnOpen: false,
|
autoLinkOnOpen: false,
|
||||||
|
downloadToastsEnabled: true,
|
||||||
|
downloadAutoRetry: false,
|
||||||
};
|
};
|
||||||
+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