Compare commits

...

36 Commits

Author SHA1 Message Date
Shozikan bf071dcfc7 Chore: Merge pull request #92 from zerebos/feat/update-panel
Feat/update panel
2026-05-21 22:56:37 -05:00
Youwes09 da788e90ba Feat: Manual Binary-Selection (CSS-WIP) (#91) 2026-05-21 14:37:53 -05:00
Zerebos b0efb183e8 Poll when updating on server 2026-05-21 02:43:06 -04:00
Zerebos 745b6993de Actually grab status from server 2026-05-21 02:33:06 -04:00
Zerebos bd79169f71 Basic caching 2026-05-21 02:23:09 -04:00
Zerebos 6fccf02614 Single line stats 2026-05-21 02:12:21 -04:00
Zerebos fa7cfdc4e6 Use stats boxes on history page 2026-05-21 02:12:21 -04:00
Zerebos 9c614b38f8 More parity between panels 2026-05-21 02:12:21 -04:00
Zerebos 30e50b5a1b Match update cards to download items 2026-05-21 02:12:21 -04:00
Zerebos 8ef0a14363 Add tab icons 2026-05-21 02:12:21 -04:00
Zerebos 4e2ad6cae7 Hoist toolbar into Recent, add status bar, dim read chapters, split cover click 2026-05-21 02:12:15 -04:00
Zerebos 9e56b1176c Integrate updates into recent activity page 2026-05-21 02:12:07 -04:00
Zerebos d025d07e07 Fix updates page data flow 2026-05-21 02:12:01 -04:00
Zerebos f988641446 Add updates page scaffold 2026-05-21 02:11:20 -04:00
Youwes09 3dad4bc729 Feat: Re-Arrangement of Folders (#86) 2026-05-19 20:36:44 -05:00
Youwes09 1af21efebd Feat: Download Storage Threshold Warning (#88) 2026-05-19 20:07:21 -05:00
Youwes09 b7197a09a7 Fix: Attempt to Patch UI Login (Not-Working) 2026-05-19 19:25:43 -05:00
Youwes09 50dd8d7e35 Fix: Preserve Token for NONE Path 2026-05-19 18:55:44 -05:00
Youwes09 b2eaea6552 Merge branch 'main' of github.com:moku-project/Moku 2026-05-19 18:53:47 -05:00
Shozikan 35aae6d85a Chore: Merge PR (#78)
Rework authentication for smoother switching between servers and auth mode
2026-05-19 18:52:48 -05:00
Youwes09 28e5f5625e Merge branch 'fix/auth' 2026-05-19 18:15:00 -05:00
Zerebos b99e4d9a3d Cleanup logs 2026-05-19 02:32:34 -04:00
Youwes09 d5f50c6495 Merge branch 'main' of https://github.com/Youwes09/Moku 2026-05-18 00:32:04 -05:00
Youwes09 89cfa50aff Fix PKGBUILD (V2) 2026-05-18 00:30:33 -05:00
Youwes09 5e591411e4 Chore: Patch PKGBUILD for AUR (V1) 2026-05-17 16:50:40 -05:00
Youwes09 8aaaf2451a Fix: Added ServerBinary Off & Flatpak Patches 2026-05-17 16:27:57 -05:00
Zerebos 75cc767b58 Expiry formatting change 2026-05-17 04:11:33 -04:00
Zerebos d30c623200 Better formatting for dates 2026-05-17 04:08:46 -04:00
Zerebos 017e9bc6da Authenticated fetch jwt settings 2026-05-17 04:00:34 -04:00
Zerebos 3b8088a2bf Decode ISO-8601 2026-05-17 03:50:39 -04:00
Zerebos 2c5320dd1f Some debug logging 2026-05-17 03:31:17 -04:00
Zerebos 1e35f304b6 Add auth debug in devtools 2026-05-17 03:29:27 -04:00
Zerebos 61339ea006 Implement jwt with refresh 2026-05-17 03:04:23 -04:00
Youwes09 f161fc08a2 Chore: Post-Bump 0.9.4 2026-05-17 00:14:22 -05:00
Zerebos bee8117aac Don't introduce a new key 2026-05-16 00:01:21 -04:00
Zerebos 0bea9c22cb Rework auth to allow smooth switching 2026-05-15 23:50:19 -04:00
35 changed files with 2378 additions and 699 deletions
+5
View File
@@ -1,11 +1,15 @@
# --- Build Artifacts --- # --- Build Artifacts ---
node_modules/ node_modules/
suwayomi-raw/
suwayomi-windows.zip
suwayomi.zip
dist/ dist/
dist-tauri/ dist-tauri/
target/ target/
bin/ bin/
out/ out/
# --- Nix --- # --- Nix ---
.direnv/ .direnv/
result result
@@ -32,6 +36,7 @@ yarn-error.log*
# --- Tauri specific --- # --- Tauri specific ---
src-tauri/target/ src-tauri/target/
src-tauri/binaries/
src-tauri/gen/ src-tauri/gen/
# --- Flatpak build artifacts --- # --- Flatpak build artifacts ---
+25 -6
View File
@@ -13,27 +13,46 @@ depends=(
) )
makedepends=( makedepends=(
'rust' 'rust'
'cargo'
'nodejs'
'pnpm' 'pnpm'
) )
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"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"
) )
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=( sha256sums=(
'4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684' 'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3' 'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
) )
b2sums=(
'SKIP'
'SKIP'
)
prepare() { prepare() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
mkdir -p src-tauri/.cargo
cat > src-tauri/.cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
EOF
} }
build() { build() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm build pnpm build
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
fixed_cflags="${fixed_cflags/-flto=auto/}"
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
CFLAGS="$fixed_cflags" \
CXXFLAGS="$fixed_cxxflags" \
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \ TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \ --release \
--manifest-path src-tauri/Cargo.toml --manifest-path src-tauri/Cargo.toml
@@ -52,7 +71,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 = true server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -105,6 +124,6 @@ LAUNCHER
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \ install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE \
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+74 -9
View File
@@ -32,6 +32,77 @@ build-options:
CARGO_HOME: /run/build/moku/cargo CARGO_HOME: /run/build/moku/cargo
modules: modules:
- name: intltool
buildsystem: autotools
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
- name: libdbusmenu
buildsystem: autotools
build-options:
cflags: -Wno-error
env:
HAVE_VALGRIND_FALSE: '#'
HAVE_VALGRIND_TRUE: ''
config-opts:
- --with-gtk=3
- --disable-static
- --disable-dumper
- --disable-tests
- --disable-gtk-doc
- --disable-vala
- --disable-introspection
cleanup:
- /include
- /libexec
- /lib/pkgconfig
- /lib/*.la
- /share/doc
- /share/libdbusmenu
- /share/gtk-doc
- /share/gir-1.0
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.3
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DENABLE_BINDINGS_MONO=OFF
- -DENABLE_BINDINGS_VALA=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- type: shell
commands:
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
- name: openjdk - name: openjdk
buildsystem: simple buildsystem: simple
build-commands: build-commands:
@@ -52,9 +123,6 @@ modules:
- type: inline - type: inline
dest-filename: catch_abort.c dest-filename: catch_abort.c
contents: | contents: |
// Linux only:
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
#define _GNU_SOURCE #define _GNU_SOURCE
#include <stdio.h> #include <stdio.h>
#include <dlfcn.h> #include <dlfcn.h>
@@ -117,19 +185,16 @@ modules:
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# Seed conf on first run
if [ ! -f "$DATA_DIR/server.conf" ]; then if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf" cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-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
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$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"
@@ -166,10 +231,10 @@ modules:
CARGO_HOME: /run/build/moku/cargo CARGO_HOME: /run/build/moku/cargo
XDG_DATA_HOME: /run/build/moku/xdg-data XDG_DATA_HOME: /run/build/moku/xdg-data
TAURI_SKIP_DEVSERVER_CHECK: 'true' TAURI_SKIP_DEVSERVER_CHECK: 'true'
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
build-commands: build-commands:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku - install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop - install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
@@ -180,7 +245,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.4 tag: v0.9.4
commit: 9f8bf6ffc11e0808acc735132e1aeff8b3bf1e09 commit: 239960683b6c7f1347e1798b0e179a8a46628728
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5 sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
+20 -3
View File
@@ -3,7 +3,12 @@ use crate::ServerState;
use tauri::Manager; use tauri::Manager;
#[tauri::command] #[tauri::command]
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> { pub fn spawn_server(
binary: String,
binary_args: Option<String>,
web_ui_enabled: bool,
app: tauri::AppHandle,
) -> Result<(), SpawnError> {
{ {
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() { if state.0.lock().unwrap().is_some() {
@@ -20,12 +25,17 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
.open(&log_path) .open(&log_path)
.ok(); .ok();
let binary_args = binary_args.unwrap_or_default();
server::do_log( server::do_log(
&mut log, &mut log,
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir), &format!(
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
binary, binary_args, web_ui_enabled, data_dir
),
); );
server::conf::seed_server_conf(&data_dir); server::conf::seed_server_conf(&data_dir, web_ui_enabled);
let mut invocation = let mut invocation =
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| { server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
@@ -33,6 +43,13 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
e e
})?; })?;
if !binary_args.trim().is_empty() {
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
let mut merged = extra;
merged.extend(invocation.args);
invocation.args = merged;
}
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") { if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!( let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}", "-Dsuwayomi.tachidesk.config.server.rootDir={}",
+28 -6
View File
@@ -1,6 +1,5 @@
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc; use crate::server::resolve::strip_unc;
#[cfg(target_os = "windows")]
use std::path::PathBuf; use std::path::PathBuf;
use tauri::Manager; use tauri::Manager;
@@ -53,6 +52,34 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
.map(|p| p.to_string()) .map(|p| p.to_string())
} }
#[tauri::command]
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
#[cfg(target_os = "windows")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
#[cfg(target_os = "macos")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "sh"]);
dialog.blocking_pick_file().map(|p| p.to_string())
}
#[tauri::command] #[tauri::command]
pub fn exit_app(app: tauri::AppHandle) { pub fn exit_app(app: tauri::AppHandle) {
app.exit(0); app.exit(0);
@@ -99,11 +126,6 @@ pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
// Note: We intentionally skip the WebView2 COM-level ClearBrowsingDataAll call here.
// The webview2_com crate pulls in a different version of windows_core than Tauri's
// own windows dependency, causing irreconcilable trait-impl conflicts at compile time.
// The filesystem cache removal below (app_cache_dir) is sufficient for our purposes;
// WebView2 will rebuild its cache on next launch from a clean directory.
window window
.with_webview(move |_wv| { .with_webview(move |_wv| {
let _ = tx.send(Ok(())); let _ = tx.send(Ok(()));
+2 -1
View File
@@ -102,6 +102,7 @@ pub fn run() {
commands::system::reset_suwayomi_data, commands::system::reset_suwayomi_data,
commands::system::open_path, commands::system::open_path,
commands::system::pick_downloads_folder, commands::system::pick_downloads_folder,
commands::system::pick_server_binary,
commands::backup::export_app_data, commands::backup::export_app_data,
commands::backup::import_app_data, commands::backup::import_app_data,
commands::backup::auto_backup_app_data, commands::backup::auto_backup_app_data,
@@ -159,5 +160,5 @@ pub fn run() {
} }
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running moku"); .expect("error while running moku")
} }
+13 -4
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1" const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = true server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
"#; "#;
pub fn seed_server_conf(data_dir: &PathBuf) { pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
let conf_path = data_dir.join("server.conf"); let conf_path = data_dir.join("server.conf");
if !conf_path.exists() { if !conf_path.exists() {
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
eprintln!("Could not create Suwayomi data dir: {e}"); eprintln!("Could not create Suwayomi data dir: {e}");
return; return;
} }
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) { let initial = patch_conf_key(
DEFAULT_SERVER_CONF.to_string(),
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
);
if let Err(e) = std::fs::write(&conf_path, initial) {
eprintln!("Could not write server.conf: {e}"); eprintln!("Could not write server.conf: {e}");
} }
return; return;
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
let patched = patch_conf_key( let patched = patch_conf_key(
patch_conf_key( patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "true"), patch_conf_key(
contents,
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
),
"server.initialOpenInBrowserEnabled", "server.initialOpenInBrowserEnabled",
"false", "false",
), ),
+66 -165
View File
@@ -1,7 +1,6 @@
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)]
@@ -48,22 +47,14 @@ pub fn strip_unc(path: PathBuf) -> PathBuf {
} }
} }
#[cfg(not(target_os = "macos"))] fn java_bin_name() -> &'static str {
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> { if cfg!(target_os = "windows") { "java.exe" } else { "java" }
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(
log,
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
);
if java.exists() {
Some(java)
} else {
None
} }
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
if java.exists() { Some(java) } else { None }
} }
fn data_root_args() -> Vec<String> { fn data_root_args() -> Vec<String> {
@@ -74,6 +65,18 @@ fn jar_data_root_flag() -> String {
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy()) format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
} }
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![
jar_data_root_flag(),
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(working_dir),
}
}
pub fn resolve_server_binary( pub fn resolve_server_binary(
binary: &str, binary: &str,
app: &tauri::AppHandle, app: &tauri::AppHandle,
@@ -83,15 +86,13 @@ pub fn resolve_server_binary(
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim())); let path = strip_unc(PathBuf::from(binary.trim()));
do_log( do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
log,
&format!("[resolve] user path: {:?} exists={}", path, path.exists()),
);
if path.exists() { if path.exists() {
let working_dir = path.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(), bin: path.to_string_lossy().into_owned(),
args: data_root_args(), args: data_root_args(),
working_dir: path.parent().map(|p| p.to_path_buf()), working_dir,
}); });
} }
do_log(log, "[resolve] user path not found, falling through"); do_log(log, "[resolve] user path not found, falling through");
@@ -101,10 +102,7 @@ pub fn resolve_server_binary(
if let Some(bin_dir) = exe.parent() { if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] { for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name); let p = bin_dir.join(name);
do_log( do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
log,
&format!("[resolve] sibling: {:?} exists={}", p, p.exists()),
);
if p.exists() { if p.exists() {
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
@@ -117,6 +115,7 @@ pub fn resolve_server_binary(
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{
let resource_dir = { let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default(); let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw); let stripped = strip_unc(raw);
@@ -124,46 +123,22 @@ pub fn resolve_server_binary(
stripped stripped
}; };
#[cfg(not(target_os = "macos"))]
{
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log( do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
log, do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
&format!(
"[resolve] bundle_dir={:?} exists={}",
bundle_dir,
bundle_dir.exists()
),
);
do_log(
log,
&format!("[resolve] jar={:?} exists={}", jar, jar.exists()),
);
match find_java_in_bundle(&bundle_dir, log) { if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
Some(java) if jar.exists() => { if jar.exists() {
do_log(log, "[resolve] using bundled JRE"); do_log(log, "[resolve] using bundled JRE + jar");
return Ok(ServerInvocation { return Ok(jar_invocation(java, jar, bundle_dir));
bin: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
} }
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
} }
for name in &[ for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
] {
let p = resource_dir.join(name); let p = resource_dir.join(name);
do_log( do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
log,
&format!("[resolve] sidecar: {:?} exists={}", p, p.exists()),
);
if p.exists() { if p.exists() {
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(), bin: p.to_string_lossy().into_owned(),
@@ -174,26 +149,16 @@ pub fn resolve_server_binary(
} }
if let Some(java) = find_java_in_bundle(&resource_dir, log) { if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir).ok().and_then(|mut rd| { let jar = std::fs::read_dir(&resource_dir)
rd.find(|e| { .ok()
e.as_ref() .and_then(|mut rd| {
.map(|e| e.file_name().to_string_lossy().ends_with(".jar")) rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.unwrap_or(false)
})
.and_then(|e| e.ok()) .and_then(|e| e.ok())
.map(|e| e.path()) .map(|e| e.path())
}); });
if let Some(jar_path) = jar { if let Some(jar_path) = jar {
do_log( do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
log, return Ok(jar_invocation(java, jar_path, resource_dir));
&format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path),
);
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
} }
} }
} }
@@ -201,108 +166,43 @@ pub fn resolve_server_binary(
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let resource_dir = app.path().resource_dir().unwrap_or_default(); let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf(); let bundle_dir = resource_dir.join("suwayomi-bundle");
do_log( do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
log, do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
);
const NATIVE_NAMES: &[&str] = &[ let java = bundle_dir.join("jre").join("bin").join("java");
"suwayomi-server-aarch64-apple-darwin", let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
"suwayomi-server-x86_64-apple-darwin", let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
"suwayomi-server", let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
let mut found_binary: Option<ServerInvocation> = None; do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
let mut found_java: Option<(PathBuf, PathBuf)> = None; do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
'outer: for depth in 0u8..=8 { if java.exists() && jar.exists() {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir) do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
.min_depth(depth as usize) return Ok(jar_invocation(java, jar, bundle_dir));
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(
log,
&format!("[resolve] scanning depth={} dir={:?}", depth, dir),
);
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() {
do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(dir.clone()),
});
break 'outer;
}
} }
if found_java.is_none() { if launcher_sh.exists() {
let java_exe = dir.join("bin").join("java"); use std::os::unix::fs::PermissionsExt;
if java_exe.exists() { let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
do_log(log, &format!("[resolve] found java: {:?}", java_exe)); do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(
log,
&format!("[resolve] found jar in bin/: {:?}", jar),
);
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(), bin: launcher_sh.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()], args: vec![],
working_dir, working_dir: Some(bundle_dir),
}); });
} }
do_log(log, "[resolve] macOS scan found nothing in bundle"); if java.exists() && launcher_jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
}
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
} }
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
@@ -314,6 +214,7 @@ pub fn resolve_server_binary(
.filter(|o| o.status.success()) .filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string())); .and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let resolved = std::process::Command::new("which") let resolved = std::process::Command::new("which")
.arg(name) .arg(name)
+4 -1
View File
@@ -138,7 +138,10 @@
startProbe(); startProbe();
if (store.settings.autoStartServer) { if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { invoke<void>("spawn_server", {
binary: store.settings.serverBinary,
webUiEnabled: store.settings.suwayomiWebUI ?? false,
}).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true; if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err); else console.warn("Could not start server:", err);
}); });
+20 -2
View File
@@ -1,5 +1,5 @@
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth"; import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
import { boot } from "@store/boot.svelte"; import { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache"; import { getBlobUrl } from "@core/cache/imageCache";
@@ -104,6 +104,15 @@ export async function gql<T>(
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
const tryRefreshAndRetry = async (): Promise<T | null> => {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode !== "UI_LOGIN" || boot.skipped) return null;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return null;
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
};
const attempt = async (): Promise<T> => { const attempt = async (): Promise<T> => {
const res = await fetchWithRetry( const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`, `${getServerUrl()}/api/graphql`,
@@ -111,12 +120,21 @@ export async function gql<T>(
signal, signal,
); );
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); if (!res.ok) {
if (res.status === 401 || res.status === 403) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
}
throw new Error(`Suwayomi HTTP ${res.status}`);
}
const json: GQLResponse<T> = await res.json(); const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) { if (json.errors?.length) {
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) { if (isAuthError && !boot.skipped) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
boot.sessionExpired = true; boot.sessionExpired = true;
boot.loginRequired = true; boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? ""; boot.loginUser = store.settings.serverAuthUser ?? "";
+9 -4
View File
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
`; `;
export const LOGIN_USER = ` export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) { mutation Login($username: String!, $password: String!, $clientMutationId: String) {
login(input: { username: $username, password: $password }) { login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
accessToken accessToken
refreshToken
clientMutationId
} }
} }
`; `;
export const REFRESH_TOKEN = ` export const REFRESH_TOKEN = `
mutation RefreshToken { mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: {}) { accessToken } refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
} }
`; `;
+6
View File
@@ -2,6 +2,12 @@ export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated { query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) { chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes { nodes {
id
name
chapterNumber
sourceOrder
isRead
lastPageRead
mangaId mangaId
fetchedAt fetchedAt
manga { id title thumbnailUrl inLibrary } manga { id title thumbnailUrl inLibrary }
+3
View File
@@ -75,6 +75,9 @@ export const LIBRARY_UPDATE_STATUS = `
manga { id title thumbnailUrl unreadCount } manga { id title thumbnailUrl unreadCount }
} }
} }
lastUpdateTimestamp {
timestamp
}
} }
`; `;
+1 -1
View File
@@ -10,7 +10,7 @@
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) | | `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats | | `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings | | `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
| `LIBRARY_UPDATE_STATUS` | — | Current library update job `jobsInfo` progress and `mangaUpdates` list with new chapters | | `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` | | `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers | | `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` | | `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
+494 -16
View File
@@ -10,19 +10,282 @@ export class AuthRequiredError extends Error {
} }
const TOKEN_KEY = "moku_access_token"; const TOKEN_KEY = "moku_access_token";
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY); const UI_SESSION_KEY = "moku_ui_auth_session";
const TOKEN_REFRESH_SKEW_MS = 30_000;
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
interface StoredAccessToken {
base: string;
token: string;
}
interface StoredUiAuthSession {
base: string;
accessToken: string;
refreshToken?: string;
clientMutationId?: string;
accessExpiresAt?: number | null;
refreshExpiresAt?: number | null;
}
interface JwtSettings {
jwtAudience?: string | null;
jwtRefreshExpiry?: string | null;
jwtTokenExpiry?: string | null;
}
export interface UiAuthDebugStatus {
mode: AuthMode;
serverBase: string;
hasSession: boolean;
hasRefreshToken: boolean;
accessExpiresAt: number | null;
refreshExpiresAt: number | null;
accessExpiresInMs: number | null;
refreshExpiresInMs: number | null;
shouldRefreshSoon: boolean;
refreshInFlight: boolean;
skewMs: number;
}
let _accessToken: string | null = null;
let _accessTokenBase: string | null = null;
let _uiSession: StoredUiAuthSession | null = null;
let _refreshPromise: Promise<string | null> | null = null;
let _jwtSettingsBase: string | null = null;
let _jwtSettings: JwtSettings | null = null;
let _jwtSettingsFetchedAt = 0;
function authDebug(event: string, fields?: Record<string, unknown>) {
if (!AUTH_DEBUG) return;
if (fields) {
console.debug(`[auth] ${event}`, fields);
return;
}
console.debug(`[auth] ${event}`);
}
function parseIsoDuration(duration: string): number | null {
try {
const match = duration.match(
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
);
if (!match) return null;
const [, years, months, days, hours, minutes, seconds] = match;
let ms = 0;
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
if (minutes) ms += parseInt(minutes) * 60 * 1000;
if (seconds) ms += parseFloat(seconds) * 1000;
return ms;
} catch {
return null;
}
}
function decodeJwtExpiryMs(token: string): number | null {
try {
const payload = token.split(".")[1];
if (!payload) return null;
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
const decoded = atob(padded);
const json = JSON.parse(decoded) as { exp?: number };
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
return Date.now() >= expiresAt - skewMs;
}
function withExpiryFromSettings(
accessToken: string,
jwt: JwtSettings | null,
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
const now = Date.now();
const accessExpiresAt =
decodeJwtExpiryMs(accessToken)
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
const refreshExpiresAt =
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
return { accessExpiresAt, refreshExpiresAt };
}
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
const res = await fetchAuthenticated(
`${base}/api/graphql`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`query GetJWTSettings {
settings {
jwtAudience
jwtRefreshExpiry
jwtTokenExpiry
}
}`,
),
},
timeoutSignal(5000),
);
if (!res.ok) {
authDebug("JWT settings fetch failed", { status: res.status });
return null;
}
const json = await res.json();
if (json?.errors?.length) {
authDebug("JWT settings query error", { errors: json.errors });
return null;
}
const settings = json?.data?.settings;
if (!settings || typeof settings !== "object") {
authDebug("JWT settings missing or invalid", { settings });
return null;
}
authDebug("JWT settings fetched", {
hasAudience: !!settings.jwtAudience,
tokenExpiry: settings.jwtTokenExpiry,
refreshExpiry: settings.jwtRefreshExpiry,
});
return {
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
};
}
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
const base = getServerBase();
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
const jwt = await fetchJwtSettings(base);
_jwtSettingsBase = base;
_jwtSettings = jwt;
_jwtSettingsFetchedAt = Date.now();
return jwt;
}
export const uiAuth = { export const uiAuth = {
getToken: () => _accessToken, getSession: () => {
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); }, const base = getServerBase();
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); }, if (_uiSession && _uiSession.base === base) return _uiSession;
const stored = readStoredSession();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(UI_SESSION_KEY);
sessionStorage.removeItem(TOKEN_KEY);
_uiSession = null;
_accessToken = null;
_accessTokenBase = null;
return null;
}
_uiSession = stored;
_accessToken = stored.accessToken;
_accessTokenBase = stored.base;
return _uiSession;
},
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
const base = getServerBase();
_uiSession = { ...session, base };
_accessToken = session.accessToken;
_accessTokenBase = base;
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
sessionStorage.removeItem(TOKEN_KEY);
},
getToken: () => {
const session = uiAuth.getSession();
if (!session) return null;
if (isExpired(session.accessExpiresAt, 0)) return null;
const base = getServerBase();
if (_accessToken && _accessTokenBase === base) return _accessToken;
const stored = readStoredToken();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
return null;
}
_accessToken = stored.token;
_accessTokenBase = stored.base;
return _accessToken;
},
setToken: (t: string) => {
const existing = uiAuth.getSession();
if (existing?.refreshToken) {
uiAuth.setSession({
...existing,
accessToken: t,
...withExpiryFromSettings(t, _jwtSettings),
});
return;
}
const base = getServerBase();
_accessToken = t;
_accessTokenBase = base;
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
},
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
uiAuth.setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
clientMutationId: payload.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
});
},
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
const existing = uiAuth.getSession();
if (!existing?.refreshToken) {
uiAuth.setToken(payload.accessToken);
return;
}
uiAuth.setSession({
...existing,
accessToken: payload.accessToken,
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
refreshToken: existing.refreshToken,
});
},
clearToken: () => {
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
},
}; };
export const authSession = { export const authSession = {
clearTokens() { uiAuth.clearToken(); }, clearTokens() {
_refreshPromise = null;
_jwtSettings = null;
_jwtSettingsBase = null;
_jwtSettingsFetchedAt = 0;
uiAuth.clearToken();
},
hasSession(): boolean { hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return _accessToken !== null; if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
return true; return true;
}, },
}; };
@@ -32,6 +295,61 @@ function getServerBase(): string {
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567"; return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
} }
function readStoredToken(): StoredAccessToken | null {
const session = readStoredSession();
if (session) return { base: session.base, token: session.accessToken };
const raw = sessionStorage.getItem(TOKEN_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
return { base: parsed.base, token: parsed.token };
} catch {}
const migrated = { base: getServerBase(), token: raw.trim() };
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
return migrated;
}
return null;
}
function readStoredSession(): StoredUiAuthSession | null {
const raw = sessionStorage.getItem(UI_SESSION_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
return {
base: parsed.base,
accessToken: parsed.accessToken,
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
};
}
} catch {}
}
const legacy = sessionStorage.getItem(TOKEN_KEY);
if (!legacy?.trim()) return null;
try {
const parsed = JSON.parse(legacy);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
} catch {}
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
function timeoutSignal(ms: number): AbortSignal { function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController(); const controller = new AbortController();
setTimeout(() => controller.abort(), ms); setTimeout(() => controller.abort(), ms);
@@ -69,27 +387,172 @@ export async function fetchAuthenticated(
} }
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
const token = uiAuth.getToken(); const token = await getUIAccessToken();
if (!token) { if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders }); if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError(); throw new AuthRequiredError();
} }
return fetch(url, {
let res = await fetch(url, {
...init, signal, credentials: "omit", ...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) }, headers: { ...baseHeaders, ...bearerHeader(token) },
}); });
if (res.status !== 401 || skipped) return res;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return res;
res = await fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
});
return res;
} }
return fetch(url, { ...init, signal, credentials: "omit" }); return fetch(url, { ...init, signal, credentials: "omit" });
} }
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (forceRefresh || isExpired(session.accessExpiresAt)) {
return refreshUiAccessToken(true);
}
return session.accessToken;
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (!session.refreshToken) {
if (force && isExpired(session.accessExpiresAt, 0)) return null;
return session.accessToken;
}
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
if (isExpired(session.refreshExpiresAt)) {
authDebug("refresh skipped: refresh token expired", {
force,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
uiAuth.clearToken();
return null;
}
if (_refreshPromise) {
authDebug("refresh joined existing request");
return _refreshPromise;
}
authDebug("refresh start", {
force,
accessExpiresAt: session.accessExpiresAt ?? null,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
_refreshPromise = (async () => {
const base = getServerBase();
const jwt = await getJwtSettings().catch(() => null);
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
}`,
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
),
signal: timeoutSignal(5000),
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
authDebug("refresh rejected by server", { status: res.status });
uiAuth.clearToken();
return null;
}
authDebug("refresh failed with HTTP error", { status: res.status });
throw new Error(`Token refresh failed (${res.status})`);
}
const json = await res.json();
const refreshed = json?.data?.refreshToken;
const nextAccessToken: string | undefined = refreshed?.accessToken;
if (!nextAccessToken) {
const msg = json?.errors?.[0]?.message;
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
authDebug("refresh rejected by GraphQL error", { message: msg });
uiAuth.clearToken();
return null;
}
authDebug("refresh returned no access token", { message: msg ?? null });
throw new Error(msg ?? "Token refresh failed");
}
uiAuth.updateAccessToken(
{
accessToken: nextAccessToken,
clientMutationId: typeof refreshed?.clientMutationId === "string"
? refreshed.clientMutationId
: session.clientMutationId,
},
jwt,
);
authDebug("refresh success", {
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
});
return nextAccessToken;
})()
.catch((e: unknown) => {
authDebug("refresh threw error", {
message: e instanceof Error ? e.message : String(e),
});
throw e;
})
.finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
const session = uiAuth.getSession();
const accessExpiresAt = session?.accessExpiresAt ?? null;
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
return {
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
serverBase: getServerBase(),
hasSession: !!session,
hasRefreshToken: !!session?.refreshToken,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
shouldRefreshSoon: isExpired(accessExpiresAt),
refreshInFlight: _refreshPromise !== null,
skewMs: TOKEN_REFRESH_SKEW_MS,
};
}
export async function loginUI(user: string, pass: string): Promise<void> { export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, { const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit", method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: gqlBody( body: gqlBody(
`mutation Login($username: String!, $password: String!) { `mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) { accessToken } login(input: { username: $username, password: $password }) {
accessToken
refreshToken
clientMutationId
}
}`, }`,
{ username: user, password: pass }, { username: user, password: pass },
), ),
@@ -97,10 +560,24 @@ export async function loginUI(user: string, pass: string): Promise<void> {
}); });
if (!res.ok) throw new Error(`Login request failed (${res.status})`); if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json(); const json = await res.json();
const token: string | undefined = json?.data?.login?.accessToken; const payload = json?.data?.login;
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); const accessToken: string | undefined = payload?.accessToken;
uiAuth.setToken(token); const refreshToken: string | undefined = payload?.refreshToken;
updateSettings({ serverAuthMode: "UI_LOGIN" }); if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
authDebug("login success", { user });
const preliminarySession = {
accessToken,
refreshToken,
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
};
uiAuth.setLoginSession(preliminarySession, null);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
const jwt = await getJwtSettings(true).catch(() => null);
uiAuth.setLoginSession(preliminarySession, jwt);
} }
export async function loginBasic(user: string, pass: string): Promise<void> { export async function loginBasic(user: string, pass: string): Promise<void> {
@@ -123,8 +600,9 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
const base = getServerBase(); const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings; const s = store.settings;
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required"; if (mode === "UI_LOGIN" && !token) return "auth_required";
try { try {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -132,8 +610,8 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
const user = s.serverAuthUser?.trim() ?? ""; const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass)); if (user && pass) Object.assign(headers, basicHeader(user, pass));
} else if (mode === "UI_LOGIN" && _accessToken) { } else if (mode === "UI_LOGIN" && token) {
Object.assign(headers, bearerHeader(_accessToken)); Object.assign(headers, bearerHeader(token));
} }
const res = await fetch(`${base}/api/graphql`, { const res = await fetch(`${base}/api/graphql`, {
+5 -4
View File
@@ -1,6 +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"; import { getUIAccessToken } 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>>();
@@ -18,10 +18,10 @@ interface QueueEntry {
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> { async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
const token = uiAuth.getToken(); const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
@@ -33,7 +33,8 @@ function getAuthHeaders(): Record<string, string> {
} }
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() }); const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`); if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob(); const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError"); if (clearing) throw new DOMException("Cancelled", "AbortError");
+1
View File
@@ -147,6 +147,7 @@ export const CACHE_GROUPS = {
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered", ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories", CATEGORIES: "categories",
SEARCH: "search_all_manga", SEARCH: "search_all_manga",
@@ -23,8 +23,28 @@
</script> </script>
{#if loading} {#if loading}
<div class="empty"> <div class="list">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /> {#each Array(5) as _, i (i)}
<div class="sk-row">
<div class="sk-thumb skeleton"></div>
<div class="sk-info">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="sk-progress-row">
<div class="skeleton sk-bar"></div>
<div class="skeleton sk-pages"></div>
</div>
</div>
<div class="sk-right">
<div class="skeleton sk-state"></div>
<div class="sk-actions">
<div class="skeleton sk-btn"></div>
</div>
</div>
</div>
{/each}
</div> </div>
{:else if queue.length === 0} {:else if queue.length === 0}
<div class="empty">Queue is empty.</div> <div class="empty">Queue is empty.</div>
@@ -49,4 +69,30 @@
<style> <style>
.list { display: flex; flex-direction: column; gap: var(--sp-2); } .list { display: flex; flex-direction: column; gap: var(--sp-2); }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); } .empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.sk-bar { flex: 1; height: 2px; }
.sk-pages { width: 28px; height: 9px; }
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.sk-state { width: 54px; height: 9px; }
.sk-actions { display: flex; gap: 2px; }
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
</style> </style>
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned]; return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
} }
const AVG_BYTES_PER_PAGE = 1_500_000;
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
let total = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
const remaining = pages - Math.round(item.progress * pages);
total += remaining * AVG_BYTES_PER_PAGE;
}
return total;
}
export function formatEta(seconds: number): string { export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`; if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
@@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import { import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany, toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta, isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
type SpeedSample, type SpeedSample,
} from "../lib/downloadQueue"; } from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry"; import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
import { invoke } from "@tauri-apps/api/core";
class DownloadStore { class DownloadStore {
status: DownloadStatus | null = $state(null); status: DownloadStatus | null = $state(null);
@@ -25,6 +26,9 @@ class DownloadStore {
batchWorking = $state(false); batchWorking = $state(false);
pagesPerSec: number | null = $state(null); pagesPerSec: number | null = $state(null);
eta: number | null = $state(null); eta: number | null = $state(null);
storageWarning: boolean = $state(false);
private freeBytes: number | null = null;
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; } get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; } get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
@@ -82,6 +86,52 @@ class DownloadStore {
this.status = ds; this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue)); setActiveDownloads(toActiveDownloads(ds.queue));
this.updateSpeed(ds); this.updateSpeed(ds);
this.fetchFreeBytes(ds);
}
private async fetchFreeBytes(ds: DownloadStatus) {
const path = store.settings.serverDownloadsPath ?? "";
if (!path) return;
try {
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
this.freeBytes = info.free_bytes;
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
} catch { }
}
private confirmStorageOverrun(): Promise<boolean> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
const panel = document.createElement("div");
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
panel.innerHTML = `
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
</div>
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
The download queue is estimated to exceed 95% of your available storage. Download anyway?
</p>
</div>
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
</div>
`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
function finish(result: boolean) { backdrop.remove(); resolve(result); }
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
});
}
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
if (this.freeBytes === null) return true;
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
return this.confirmStorageOverrun();
} }
private updateSpeed(ds: DownloadStatus) { private updateSpeed(ds: DownloadStatus) {
@@ -172,11 +222,21 @@ class DownloadStore {
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
async enqueue(chapterId: number): Promise<boolean> {
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
if (!(await this.guardStorage(projected))) return false;
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); }
return true;
}
async retryOne(chapterId: number) { async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return; if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId); this.dequeueing = new Set(this.dequeueing).add(chapterId);
try { try {
await gql(DEQUEUE_DOWNLOAD, { chapterId }); await gql(DEQUEUE_DOWNLOAD, { chapterId });
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
if (!(await this.guardStorage(projected))) { this.poll(); return; }
await gql(ENQUEUE_DOWNLOAD, { chapterId }); await gql(ENQUEUE_DOWNLOAD, { chapterId });
this.poll(); this.poll();
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
@@ -189,6 +249,8 @@ class DownloadStore {
const ids = [...this.erroredIds]; const ids = [...this.erroredIds];
try { try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll(); this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
@@ -204,6 +266,8 @@ class DownloadStore {
try { try {
if (ids.length > 0) { if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers"; import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
interface Props { interface Props {
@@ -40,6 +40,15 @@
{/if} {/if}
{#each FILTERS as f} {#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}> <button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
{#if f.id === "installed"}
<CheckCircle size={11} weight="bold" />
{:else if f.id === "available"}
<Globe size={11} weight="bold" />
{:else if f.id === "updates"}
<ArrowCircleUp size={11} weight="bold" />
{:else if f.id === "all"}
<Rows size={11} weight="bold" />
{/if}
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label} {f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button> </button>
{/each} {/each}
+29 -19
View File
@@ -71,8 +71,8 @@
let activeDragKind: "tab" | null = $state(null); let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: string | null = $state(null);
let dragOverTabId: number | null = $state(null); let dragOverTabId: string | null = $state(null);
const DT_TAB = "application/x-moku-tab"; const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
@@ -95,7 +95,8 @@
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id)); const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const pinned = store.settings.libraryPinnedTabOrder ?? []; const pinned = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set([...BUILTIN_TABS, ...catIds]); const known = new Set([...BUILTIN_TABS, ...catIds]);
const ordered = [...pinned.filter(id => known.has(id))]; const eligible = pinned.filter(id => known.has(id));
const ordered = [...eligible];
const inOrder = new Set(ordered); const inOrder = new Set(ordered);
for (const id of [...BUILTIN_TABS, ...catIds]) { for (const id of [...BUILTIN_TABS, ...catIds]) {
if (!inOrder.has(id)) ordered.push(id); if (!inOrder.has(id)) ordered.push(id);
@@ -517,46 +518,55 @@
}); });
} }
function onTabDragStart(e: DragEvent, cat: Category) { function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = "tab"; dragTabId = cat.id; activeDragKind = "tab"; dragTabId = id;
e.dataTransfer!.effectAllowed = "move"; e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData(DT_TAB, String(cat.id)); e.dataTransfer!.setData(DT_TAB, id);
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`); e.dataTransfer!.setData("text/plain", `tab:${id}`);
} }
function onTabDragOver(e: DragEvent, cat: Category, idx: number) { function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return; if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move"; e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id; dragOverTabId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1; dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
} }
function onTabDragLeave() { dragOverTabId = null; } function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) { async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null; e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx; const insertAt = dragInsertIdx;
dragInsertIdx = -1; dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; } if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null; const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
const dragStrId = String(dragId);
const tabs = [...visibleTabIds]; const tabs = [...allTabIds];
const fromIdx = tabs.indexOf(dragStrId); const fromIdx = tabs.indexOf(dragStrId);
if (fromIdx < 0) return; const dropIdx = tabs.indexOf(dropId);
if (fromIdx < 0 || dropIdx < 0) return;
const visibleDrop = visibleTabIds[insertAt] ?? null;
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
tabs.splice(fromIdx, 1); tabs.splice(fromIdx, 1);
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length)); const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
tabs.splice(dest, 0, dragStrId); tabs.splice(adjustedDest, 0, dragStrId);
updateSettings({ libraryPinnedTabOrder: tabs }); updateSettings({ libraryPinnedTabOrder: tabs });
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded"); const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
const zeroCat = store.categories.filter(c => c.id === 0); const zeroCat = store.categories.filter(c => c.id === 0);
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; }); const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
setCategories([...zeroCat, ...reordered]); setCategories([...zeroCat, ...reordered]);
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
if (!dragIsBuiltin) {
const serverPos = catIds.indexOf(dragStrId) + 1; const serverPos = catIds.indexOf(dragStrId) + 1;
try { try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos }); await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); } } catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
} }
}
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; } function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
@@ -24,8 +24,8 @@
search: string; search: string;
activeDragKind: "tab" | null; activeDragKind: "tab" | null;
dragInsertIdx: number; dragInsertIdx: number;
dragTabId: number | null; dragTabId: string | null;
dragOverTabId: number | null; dragOverTabId: string | null;
sortPanelOpen: boolean; sortPanelOpen: boolean;
filterPanelOpen: boolean; filterPanelOpen: boolean;
tabsEl: HTMLDivElement; tabsEl: HTMLDivElement;
@@ -39,10 +39,10 @@
onSortPanelToggle: () => void; onSortPanelToggle: () => void;
onFilterPanelToggle: () => void; onFilterPanelToggle: () => void;
onOpenDownloadsFolder: () => void; onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void; onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void; onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
onTabDragLeave: () => void; onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void; onTabDrop: (e: DragEvent, id: string) => void;
onTabDragEnd: () => void; onTabDragEnd: () => void;
} }
@@ -100,20 +100,23 @@
{#each visibleTabIds as id, idx} {#each visibleTabIds as id, idx}
{@const cat = visibleCategories.find(c => String(c.id) === id)} {@const cat = visibleCategories.find(c => String(c.id) === id)}
{#if id === "library" || id === "downloaded" || cat} {#if id === "library" || id === "downloaded" || cat}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = cat && id === String(completedCatId)}
{@const isDraggable = true}
{#if activeDragKind === "tab" && dragInsertIdx === idx} {#if activeDragKind === "tab" && dragInsertIdx === idx}
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tab-insert-bar" aria-hidden="true"></div>
{/if} {/if}
<button <button
class="tab" class="tab"
class:active={tab === id} class:active={tab === id}
class:tab-dragging={cat && dragTabId === cat.id} class:tab-dragging={isDraggable && dragTabId === id}
draggable={!!cat && id !== String(completedCatId)} draggable={isDraggable}
onclick={() => onTabChange(id)} onclick={() => onTabChange(id)}
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined} ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined} ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined} ondragleave={isDraggable ? onTabDragLeave : undefined}
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined} ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined} ondragend={isDraggable ? onTabDragEnd : undefined}
> >
{#if id === "library"}<Books size={11} weight="bold" /> {#if id === "library"}<Books size={11} weight="bold" />
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" /> {:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte"; import { ClockCounterClockwise, 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, setPreviewManga } from "@store/state.svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
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";
@@ -10,6 +10,13 @@
import type { Manga } from "@types"; import type { Manga } from "@types";
import { timeAgo, dayLabel, formatReadTime } from "@core/util"; import { timeAgo, dayLabel, formatReadTime } from "@core/util";
interface Props {
search: string;
confirmClear: boolean;
}
let { search, confirmClear }: Props = $props();
let libraryManga = $state<Manga[]>([]); let libraryManga = $state<Manga[]>([]);
onMount(() => { onMount(() => {
@@ -24,9 +31,6 @@
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? ""; return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
} }
let search = $state("");
let confirmClear = $state(false);
const SESSION_GAP_MS = 30 * 60 * 1000; const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session { interface Session {
@@ -90,83 +94,9 @@
} }
return Array.from(map.entries()).map(([label, items]) => ({ label, items })); return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
}); });
function handleClear() {
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
clearHistory(); confirmClear = false;
}
</script> </script>
<div class="root anim-fade-in"> <div class="root anim-fade-in">
<div class="header">
<div class="heading-group">
<ClockCounterClockwise size={13} weight="light" class="heading-icon" />
<span class="heading">History</span>
</div>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={11} class="search-icon" weight="light" />
<input class="search" placeholder="Search…" bind:value={search} />
{#if search}
<button class="search-clear" onclick={() => search = ""}>×</button>
{/if}
</div>
{#if store.history.length > 0}
<button
class="clear-btn"
class:confirm={confirmClear}
onclick={handleClear}
title={confirmClear ? "Click again to confirm" : "Clear history"}
>
<Trash size={12} weight="light" />
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
{/if}
</div>
</div>
{#if store.readingStats.totalChaptersRead > 0}
<div class="stats-grid">
<div class="stat-card streak">
<div class="stat-icon-wrap fire">
<Fire size={12} weight="fill" />
</div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.currentStreakDays}</span>
<span class="stat-unit">day streak</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap">
<BookOpen size={12} weight="light" />
</div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
<span class="stat-unit">chapters</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap">
<Clock size={12} weight="light" />
</div>
<div class="stat-body">
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
<span class="stat-unit">read time</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap">
<TrendUp size={12} weight="light" />
</div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
<span class="stat-unit">series</span>
</div>
</div>
</div>
{/if}
{#if store.history.length === 0} {#if store.history.length === 0}
<div class="empty"> <div class="empty">
<div class="empty-icon-wrap"> <div class="empty-icon-wrap">
@@ -184,6 +114,44 @@
</div> </div>
{:else} {:else}
<div class="timeline"> <div class="timeline">
{#if store.readingStats.totalChaptersRead > 0}
<div class="stats-section">
<div class="stats-header">
<span class="stats-title"><TrendUp size={10} weight="bold" /> Reading Stats</span>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon-wrap fire"><Fire size={14} weight="fill" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.currentStreakDays}</span>
<span class="stat-label">Day streak</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap accent"><BookOpen size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
<span class="stat-label">Chapters read</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap neutral"><Clock size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
<span class="stat-label">Read time</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap neutral"><TrendUp size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
<span class="stat-label">Series read</span>
</div>
</div>
</div>
</div>
{/if}
{#each groups as { label, items }} {#each groups as { label, items }}
<div class="day-group"> <div class="day-group">
<div class="day-header"> <div class="day-header">
@@ -230,180 +198,105 @@
overflow: hidden; overflow: hidden;
} }
.header { .stats-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; padding-bottom: var(--sp-2);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
} }
.heading-group { .stats-title {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
}
:global(.heading-icon) { color: var(--text-faint); }
.heading {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-2xs);
font-weight: var(--weight-medium); color: var(--text-faint);
color: var(--text-muted);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
} }
.header-right {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-wrap :global(.search-icon) {
position: absolute;
left: 8px;
color: var(--text-faint);
pointer-events: none;
}
.search {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 4px 26px;
color: var(--text-primary);
font-size: var(--text-xs);
width: 148px;
outline: none;
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus {
border-color: var(--border-strong);
background: var(--bg-elevated);
width: 200px;
}
.search-clear {
position: absolute;
right: 8px;
color: var(--text-faint);
font-size: 13px;
line-height: 1;
background: none;
border: none;
cursor: pointer;
padding: 2px;
transition: color var(--t-base);
}
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; gap: 4px;
height: 30px; padding: 0 var(--sp-2);
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1px; gap: var(--sp-2);
background: var(--border-dim);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
} }
.stat-card { .stat-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); background: var(--bg-raised);
background: var(--bg-base); border: 1px solid var(--border-dim);
transition: background var(--t-base); border-radius: var(--radius-md);
padding: var(--sp-3);
transition: border-color var(--t-fast);
} }
.stat-card.streak .stat-icon-wrap { background: color-mix(in srgb, #f97316 12%, transparent); } .stat-card:hover { border-color: var(--border-base); }
.stat-card.streak .stat-val { color: #f97316; }
.stat-icon-wrap { .stat-icon-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 32px;
height: 24px; height: 32px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-faint);
flex-shrink: 0; flex-shrink: 0;
} }
.stat-icon-wrap.fire { color: #f97316; } .fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-body { .stat-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 2px;
min-width: 0; min-width: 0;
} }
.stat-val { .stat-val {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-sm); font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-semibold); font-weight: var(--weight-medium);
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1; line-height: 1;
letter-spacing: -0.01em;
} }
.stat-unit { .stat-label {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 9px; font-size: var(--text-2xs);
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
} }
.timeline { .timeline {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: var(--sp-4) var(--sp-5) var(--sp-6); padding: var(--sp-4) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-5);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent; scrollbar-color: var(--border-dim) transparent;
} }
.day-group { margin-bottom: var(--sp-5); } .day-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.day-header { .day-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
padding-bottom: var(--sp-2);
} }
.day-label { .day-label {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 9px; font-size: var(--text-2xs);
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
@@ -414,12 +307,12 @@
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--border-dim); background: var(--border-dim);
opacity: 0.5;
} }
.session-list { .session-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-2);
} }
.session-row { .session-row {
@@ -427,17 +320,16 @@
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
width: 100%; width: 100%;
padding: var(--sp-2) var(--sp-2); padding: var(--sp-3);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: none; border: 1px solid var(--border-dim);
background: none; background: var(--bg-raised);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background var(--t-fast); transition: border-color var(--t-fast), background var(--t-fast);
} }
.session-row:hover { background: var(--bg-raised); } .session-row:hover { border-color: var(--border-strong); background: var(--bg-elevated); }
.session-row:active { background: var(--bg-elevated); }
.thumb-wrap { .thumb-wrap {
position: relative; position: relative;
@@ -494,8 +386,8 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-xs);
color: var(--text-faint); color: var(--text-muted);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -0,0 +1,234 @@
<script lang="ts">
import { ArrowCircleUp, ArrowsClockwise, BookOpen, CircleNotch, MagnifyingGlass, NewspaperClipping, Trash } from "phosphor-svelte";
import { store, clearHistory } from "@store/state.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import UpdatesPanel from "./UpdatesPanel.svelte";
type RecentTab = "updates" | "history";
let tab = $state<RecentTab>("updates");
// History toolbar state
let historySearch = $state("");
let historyConfirmClear = $state(false);
function handleHistoryClear() {
if (!historyConfirmClear) {
historyConfirmClear = true;
setTimeout(() => { historyConfirmClear = false; }, 3000);
return;
}
clearHistory();
historyConfirmClear = false;
}
// Updates toolbar state — bound to the child panel
let updatesLoading = $state(true);
let updatesRefreshFn = $state<(() => Promise<void>) | null>(null);
</script>
<div class="root anim-fade-in">
<div class="header">
<span class="heading">Recent</span>
<div class="tabs">
<button class="tab" class:active={tab === "updates"} onclick={() => tab = "updates"}>
<NewspaperClipping size={11} weight="bold" />
Updates
</button>
<button class="tab" class:active={tab === "history"} onclick={() => tab = "history"}>
<BookOpen size={11} weight="bold" />
Reading history
</button>
</div>
<div class="header-right">
{#if tab === "updates"}
<button class="icon-btn" onclick={() => updatesRefreshFn?.()} disabled={updatesLoading} title="Refresh updates">
{#if updatesLoading}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowsClockwise size={14} weight="bold" />
{/if}
</button>
{:else}
<div class="search-wrap">
<MagnifyingGlass size={11} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search…"
value={historySearch}
oninput={(e) => historySearch = (e.target as HTMLInputElement).value}
/>
{#if historySearch}
<button class="search-clear" onclick={() => historySearch = ""}>×</button>
{/if}
</div>
{#if store.history.length > 0}
<button
class="clear-btn"
class:confirm={historyConfirmClear}
onclick={handleHistoryClear}
title={historyConfirmClear ? "Click again to confirm" : "Clear history"}
>
<Trash size={12} weight="light" />
{#if historyConfirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
{/if}
{/if}
</div>
</div>
<div class="content">
{#if tab === "updates"}
<UpdatesPanel
bind:loading={updatesLoading}
onRegisterRefresh={(fn) => updatesRefreshFn = fn}
/>
{:else}
<HistoryPanel search={historySearch} confirmClear={historyConfirmClear} />
{/if}
</div>
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.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;
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
flex-shrink: 0;
}
.tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 2px;
}
.tab {
display: flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 4px 10px;
border-radius: var(--radius-sm);
color: var(--text-faint);
white-space: nowrap;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
border: 1px solid transparent;
}
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.header-right {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-left: auto;
flex-shrink: 0;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn:disabled { opacity: 0.45; cursor: default; }
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-wrap :global(.search-icon) {
position: absolute;
left: 8px;
color: var(--text-faint);
pointer-events: none;
}
.search {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 4px 26px;
color: var(--text-primary);
font-size: var(--text-xs);
width: 148px;
outline: none;
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); background: var(--bg-elevated); width: 200px; }
.search-clear {
position: absolute;
right: 8px;
color: var(--text-faint);
font-size: 13px;
line-height: 1;
background: none;
border: none;
cursor: pointer;
padding: 2px;
transition: color var(--t-base);
}
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; gap: 4px;
height: 28px; padding: 0 var(--sp-2);
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
.content {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,631 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { BookOpen, CircleNotch } from "phosphor-svelte";
import { gql } from "@api/client";
import { GET_RECENTLY_UPDATED, GET_CHAPTERS, LIBRARY_UPDATE_STATUS } from "@api/queries";
import { cache, CACHE_GROUPS, CACHE_KEYS } from "@core/cache";
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
import { dayLabel } from "@core/util";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Chapter, Manga } from "@types";
interface Props {
loading?: boolean;
onRegisterRefresh?: (fn: () => Promise<void>) => void;
}
let { loading = $bindable(true), onRegisterRefresh }: Props = $props();
interface RecentUpdate extends Pick<Chapter, "id" | "name" | "chapterNumber" | "sourceOrder" | "isRead" | "lastPageRead" | "mangaId" | "fetchedAt"> {
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null;
}
interface UpdateGroup {
label: string;
items: RecentUpdate[];
}
let updates = $state<RecentUpdate[]>([]);
let error = $state<string | null>(null);
let openingId = $state<number | null>(null);
let updaterRunning = $state(false);
let lastUpdatedTs = $state<number | null>(null);
let updaterFinishedJobs = $state<number | null>(null);
let updaterTotalJobs = $state<number | null>(null);
let ctrl: AbortController | null = null;
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
const RECENT_UPDATES_TTL_MS = 60 * 1_000;
const UPDATE_STATUS_POLL_MS = 2_000;
onMount(() => {
onRegisterRefresh?.(() => loadUpdates(true));
void loadUpdates();
});
onDestroy(() => {
ctrl?.abort();
stopStatusPolling();
});
function fetchedAtMs(item: Pick<RecentUpdate, "fetchedAt">): number {
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now();
return Number.isFinite(ts) ? ts : Date.now();
}
const groups = $derived.by(() => {
const grouped: Record<string, RecentUpdate[]> = {};
for (const item of updates) {
const label = dayLabel(fetchedAtMs(item));
if (!grouped[label]) grouped[label] = [];
grouped[label].push(item);
}
return Object.entries(grouped).map(([label, items]) => ({ label, items })) as UpdateGroup[];
});
const lastUpdatedLabel = $derived(
lastUpdatedTs
? new Date(lastUpdatedTs).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
: null
);
const updaterProgressLabel = $derived(
typeof updaterFinishedJobs === "number" && typeof updaterTotalJobs === "number" && updaterTotalJobs > 0
? `${updaterFinishedJobs}/${updaterTotalJobs}`
: null
);
function parseServerTimestamp(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function applyUpdateStatus(statusRes: {
libraryUpdateStatus: {
jobsInfo: {
isRunning: boolean;
finishedJobs?: number;
totalJobs?: number;
};
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
} | null) {
const jobsInfo = statusRes?.libraryUpdateStatus.jobsInfo;
updaterRunning = jobsInfo?.isRunning ?? false;
updaterFinishedJobs = typeof jobsInfo?.finishedJobs === "number" ? jobsInfo.finishedJobs : null;
updaterTotalJobs = typeof jobsInfo?.totalJobs === "number" ? jobsInfo.totalJobs : null;
lastUpdatedTs = parseServerTimestamp(statusRes?.lastUpdateTimestamp?.timestamp ?? null);
}
function stopStatusPolling() {
if (!statusPollTimer) return;
clearTimeout(statusPollTimer);
statusPollTimer = null;
}
function scheduleStatusPoll() {
if (statusPollTimer) return;
const tick = async () => {
statusPollTimer = null;
try {
const statusRes = await gql<{
libraryUpdateStatus: {
jobsInfo: {
isRunning: boolean;
finishedJobs: number;
totalJobs: number;
};
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
}>(LIBRARY_UPDATE_STATUS, {});
const wasRunning = updaterRunning;
applyUpdateStatus(statusRes);
if (updaterRunning) {
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
} else if (wasRunning) {
void loadUpdates(true);
}
} catch {
if (updaterRunning) {
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
}
}
};
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
}
function mangaStub(item: RecentUpdate): Manga {
return {
id: item.manga?.id ?? item.mangaId,
title: item.manga?.title ?? "Unknown series",
thumbnailUrl: item.manga?.thumbnailUrl ?? "",
inLibrary: item.manga?.inLibrary ?? true,
};
}
function chapterLabel(item: RecentUpdate): string {
if (item.name?.trim()) return item.name;
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`;
return "Chapter";
}
async function loadUpdates(force = false) {
ctrl?.abort();
const nextCtrl = new AbortController();
ctrl = nextCtrl;
loading = true;
error = null;
try {
const key = CACHE_KEYS.RECENT_UPDATES;
if (force) cache.clear(key);
const [updatesRes, statusRes] = await Promise.all([
cache.get<{ chapters: { nodes: RecentUpdate[] } }>(
key,
() => gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal),
RECENT_UPDATES_TTL_MS,
CACHE_GROUPS.LIBRARY,
),
gql<{
libraryUpdateStatus: {
jobsInfo: { isRunning: boolean };
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
}>(LIBRARY_UPDATE_STATUS, {}, nextCtrl.signal).catch(() => null),
]);
applyUpdateStatus(statusRes);
if (updaterRunning) scheduleStatusPoll();
else stopStatusPolling();
if (nextCtrl.signal.aborted) return;
updates = updatesRes.chapters.nodes
.filter(item => item.manga?.inLibrary)
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a));
} catch (e: any) {
if (nextCtrl.signal.aborted) return;
error = e?.message ?? "Failed to load updates";
updates = [];
updaterRunning = false;
lastUpdatedTs = null;
updaterFinishedJobs = null;
updaterTotalJobs = null;
stopStatusPolling();
} finally {
if (!nextCtrl.signal.aborted) loading = false;
}
}
async function openUpdate(item: RecentUpdate) {
if (openingId !== null) return;
openingId = item.id;
const manga = mangaStub(item);
try {
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: item.mangaId });
const raw = [...res.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[item.mangaId]);
const target = list.find(ch => ch.id === item.id);
if (target) {
setActiveManga(manga);
openReader(target, list);
} else {
setActiveManga(manga);
}
} catch {
setActiveManga(manga);
addToast({ kind: "error", title: "Couldn't open chapter", body: "Opened the series instead." });
} finally {
openingId = null;
}
}
</script>
<div class="root anim-fade-in">
<div class="bar-wrap">
<div class="status-bar">
<div class="status-dot" class:active={loading || updaterRunning}></div>
<span class="status-text">
{#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress...{#if updaterProgressLabel} ({updaterProgressLabel}){/if}{:else}Up to date{/if}
</span>
<div class="status-right">
{#if !loading && lastUpdatedLabel}
<span class="status-detail">Last updated: {lastUpdatedLabel}</span>
<div class="bar-sep"></div>
{/if}
{#if !loading && updates.length > 0}
<span class="status-count">{updates.length} chapter{updates.length === 1 ? "" : "s"}</span>
{/if}
</div>
</div>
</div>
{#if loading && updates.length === 0}
<div class="timeline" aria-hidden="true">
<section class="day-group">
<div class="day-header">
<span class="day-label skeleton sk-day-label"></span>
<div class="day-rule skeleton sk-day-rule"></div>
</div>
<div class="updates-list">
{#each Array(8) as _, i (i)}
<div class="update-row skeleton-row">
<div class="thumb-skeleton skeleton"></div>
<div class="info-skeleton">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="skeleton sk-meta"></div>
</div>
<div class="end-skeleton skeleton"></div>
</div>
{/each}
</div>
</section>
</div>
{:else if error}
<div class="empty">
<div class="empty-icon-wrap">
<BookOpen size={22} weight="light" />
</div>
<p class="empty-text">Couldn't load updates</p>
<p class="empty-hint">{error}</p>
</div>
{:else if updates.length === 0}
<div class="empty">
<div class="empty-icon-wrap">
<BookOpen size={22} weight="light" />
</div>
<p class="empty-text">No recent library updates</p>
<p class="empty-hint">Run a library update to populate this page.</p>
</div>
{:else}
<div class="timeline">
{#each groups as { label, items } (label)}
<section class="day-group">
<div class="day-header">
<span class="day-label">{label}</span>
<div class="day-rule"></div>
</div>
<div class="updates-list">
{#each items as item (item.id)}
<div class="update-row" class:read={item.isRead}>
<button class="thumb-btn" onclick={() => setActiveManga(mangaStub(item))} title="View series">
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
</button>
<button class="info-btn" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
<div class="update-info">
<div class="title-row">
<span class="series-title">{item.manga?.title ?? "Unknown series"}</span>
{#if !item.isRead}
<span class="pill">Unread</span>
{/if}
</div>
<span class="chapter-title">{chapterLabel(item)}</span>
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
<div class="meta-row">
<span>Resume p.{item.lastPageRead}</span>
</div>
{/if}
</div>
<div class="row-end">
{#if openingId === item.id}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<BookOpen size={14} weight="light" />
{/if}
</div>
</button>
</div>
{/each}
</div>
</section>
{/each}
</div>
{/if}
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.bar-wrap { padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.status-detail { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.timeline {
flex: 1;
overflow-y: auto;
padding: var(--sp-4) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.day-header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.day-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
white-space: nowrap;
}
.day-rule {
height: 1px;
flex: 1;
background: var(--border-dim);
}
.updates-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.update-row {
display: flex;
align-items: stretch;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
overflow: hidden;
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
}
.update-row:has(.info-btn:hover:not(:disabled)),
.update-row:has(.thumb-btn:hover) {
border-color: var(--border-strong);
background: var(--bg-elevated);
}
.update-row.read { opacity: 0.5; }
.skeleton-row {
min-height: 74px;
pointer-events: none;
}
.thumb-skeleton {
width: 34px;
aspect-ratio: 2 / 3;
margin: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
flex-shrink: 0;
align-self: center;
}
.info-skeleton {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
}
.sk-title { height: 12px; width: clamp(140px, 42%, 340px); }
.sk-chapter { height: 10px; width: clamp(100px, 30%, 260px); }
.sk-meta { height: 8px; width: clamp(70px, 18%, 180px); }
.end-skeleton {
width: 14px;
height: 14px;
border-radius: 50%;
margin: auto var(--sp-4) auto 0;
opacity: 0.85;
flex-shrink: 0;
}
.sk-day-label {
display: block;
width: 74px;
height: 10px;
border-radius: var(--radius-sm);
}
.sk-day-rule {
opacity: 0.7;
}
.thumb-btn {
width: 52px;
flex-shrink: 0;
padding: var(--sp-2);
background: none;
border: none;
/* border-right: 1px solid var(--border-dim); */
cursor: pointer;
display: flex;
align-items: center;
transition: background var(--t-base);
}
.thumb-btn:hover { background: none; }
:global(.thumb) {
width: 100%;
aspect-ratio: 2 / 3;
display: block;
object-fit: cover;
border-radius: var(--radius-sm);
}
.info-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-base);
}
.info-btn:hover:not(:disabled) { background: none; }
.info-btn:disabled { cursor: default; opacity: 0.8; }
.update-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.title-row {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.series-title,
.chapter-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.series-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-primary);
}
.chapter-title {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
}
.meta-row {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.pill {
padding: 2px 6px;
border-radius: var(--radius-full);
background: var(--accent-muted);
color: var(--accent-fg);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
flex-shrink: 0;
}
.row-end {
color: var(--text-faint);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
flex-shrink: 0;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
color: var(--text-faint);
padding: var(--sp-6);
text-align: center;
}
.empty-icon-wrap {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
}
.empty-text {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.empty-hint {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
</style>
@@ -10,7 +10,7 @@
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga"; import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters"; import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
import { import {
store, addToast, openReader, setActiveManga, store, addToast, openReader, setActiveManga,
@@ -321,7 +321,7 @@
async function enqueue(ch: Chapter, e: MouseEvent) { async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
enqueueing = new Set(enqueueing).add(ch.id); enqueueing = new Set(enqueueing).add(ch.id);
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error); await downloadStore.enqueue(ch.id);
addToast({ kind: "download", title: "Download queued", body: ch.name }); addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing); enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (store.activeManga) reloadChapters(store.activeManga.id); if (store.activeManga) reloadChapters(store.activeManga.id);
@@ -329,7 +329,10 @@
async function enqueueMultiple(chapterIds: number[]) { async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return; if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); for (const id of chapterIds) {
const allowed = await downloadStore.enqueue(id);
if (!allowed) return;
}
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` }); addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (store.activeManga) reloadChapters(store.activeManga.id); if (store.activeManga) reloadChapters(store.activeManga.id);
} }
@@ -461,7 +464,7 @@
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 }, { label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 }, { label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
{ separator: true }, { separator: true },
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) }, { label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : downloadStore.enqueue(ch.id) },
{ separator: true }, { separator: true },
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) }, { label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) }, { label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
@@ -2,6 +2,7 @@
import ThreeDCard from "@shared/manga/ThreeDCard.svelte"; import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
import { store, addToast } from "@store/state.svelte"; import { store, addToast } from "@store/state.svelte";
import { cache } from "@core/cache/index"; import { cache } from "@core/cache/index";
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from "@core/auth";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; } interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
@@ -12,13 +13,69 @@
let appVersion = $state("…"); let appVersion = $state("…");
let helloAvailable = $state<boolean | null>(null); let helloAvailable = $state<boolean | null>(null);
let helloBusy = $state(false); let helloBusy = $state(false);
let authStatus = $state<UiAuthDebugStatus | null>(null);
let authRefreshBusy = $state(false);
$effect(() => { $effect(() => {
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {}); import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
refreshPerfMetrics(); refreshPerfMetrics();
refreshAuthStatus();
invoke<boolean>("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false); invoke<boolean>("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false);
const timer = setInterval(() => refreshAuthStatus(), 1000);
return () => clearInterval(timer);
}); });
function refreshAuthStatus() {
authStatus = getUiAuthDebugStatus();
}
function fmtCountdown(ms: number | null): string {
if (ms === null) return "—";
if (ms <= 0) return "expired";
const total = Math.floor(ms / 1000);
const month = 30 * 24 * 60 * 60;
const day = 24 * 60 * 60;
const hour = 60 * 60;
const minute = 60;
const months = Math.floor(total / month);
const days = Math.floor((total % month) / day);
const hours = Math.floor(total / 3600);
const remainingHours = Math.floor((total % day) / hour);
const mins = Math.floor((total % hour) / minute);
const secs = total % 60;
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`;
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
function fmtTime(ts: number | null): string {
if (ts === null) return "—";
return new Date(ts).toLocaleString([], { dateStyle: "medium", timeStyle: "medium" });
}
async function forceTokenRefresh() {
authRefreshBusy = true;
try {
const token = await refreshUiAccessToken(true);
addToast({
kind: token ? "success" : "info",
title: "UI auth refresh",
body: token ? "Refresh succeeded" : "No refreshed token available",
});
} catch (e: any) {
addToast({ kind: "error", title: "UI auth refresh", body: String(e?.message ?? e) });
} finally {
authRefreshBusy = false;
refreshAuthStatus();
}
}
function refreshPerfMetrics() { function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null; let entries = 0, oldest: number | null = null, newest: number | null = null;
const foundKeys: string[] = []; const foundKeys: string[] = [];
@@ -75,7 +132,7 @@
<div class="s-row"> <div class="s-row">
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div> <div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
<div class="s-dev-pill-group"> <div class="s-dev-pill-group">
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]} {#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label] (kind)}
<button class="s-dev-pill {kind}" onclick={() => addToast({ <button class="s-dev-pill {kind}" onclick={() => addToast({
kind, kind,
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete", title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
@@ -122,7 +179,7 @@
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)"> <div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
<span class="s-desc">3D tilt cards — hover to preview</span> <span class="s-desc">3D tilt cards — hover to preview</span>
<div style="display:flex;gap:var(--sp-3)"> <div style="display:flex;gap:var(--sp-3)">
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card} {#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card (card.title)}
<ThreeDCard> <ThreeDCard>
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)"> <div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span> <span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
@@ -159,4 +216,32 @@
</div> </div>
</div> </div>
<div class="s-section">
<p class="s-section-title">Auth (UI Login)</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? "—"}</span>
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? "present" : "none"}</span>
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? "present" : "none"}</span>
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? "open" : "not yet"}</span>
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? "yes" : "no"}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
</div>
<div class="s-btn-row">
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh} disabled={authRefreshBusy || authStatus?.mode !== "UI_LOGIN" || !authStatus?.hasRefreshToken}>
{authRefreshBusy ? "Refreshing…" : "Force refresh"}
</button>
</div>
</div>
</div>
</div>
</div> </div>
@@ -9,10 +9,12 @@
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null); const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
const completedId = $derived(completedCat ? String(completedCat.id) : null); const completedId = $derived(completedCat ? String(completedCat.id) : null);
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id))); const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
const orderedCatIds = $derived.by(() => {
const orderedAllIds = $derived.by(() => {
const order = store.settings.libraryPinnedTabOrder ?? []; const order = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set(sortedCatIds); const allIds = ["library", "downloaded", ...sortedCatIds];
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))]; const known = new Set(allIds);
return [...new Set([...order.filter(id => known.has(id)), ...allIds])];
}); });
let catsLoading = $state(false); let catsLoading = $state(false);
@@ -21,8 +23,8 @@
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 dragStrId = $state<string | null>(null);
let dragOverId = $state<number | null>(null); let dragOverStrId = $state<string | null>(null);
let dropPosition = $state<"above" | "below" | null>(null); let dropPosition = $state<"above" | "below" | null>(null);
function isHidden(id: string) { function isHidden(id: string) {
@@ -92,22 +94,31 @@
} }
} }
async function applyReorder(fromId: number, toId: number) { function applyReorder(fromStrId: string, toStrId: string) {
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const allIds = ["library", "downloaded", ...catIds];
const current = store.settings.libraryPinnedTabOrder ?? [];
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])];
const fromIdx = base.indexOf(fromStrId);
const toIdx = base.indexOf(toStrId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
base.splice(fromIdx, 1);
base.splice(toIdx, 0, fromStrId);
updateSettings({ libraryPinnedTabOrder: base });
const fromNumId = Number(fromStrId);
if (!isNaN(fromNumId) && fromStrId !== "library" && fromStrId !== "downloaded") {
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 fromIdx = sortable.findIndex(c => c.id === fromId); const sFromIdx = sortable.findIndex(c => c.id === fromNumId);
const toIdx = sortable.findIndex(c => c.id === toId); const sToIdx = sortable.findIndex(c => String(c.id) === toStrId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return; if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
const reordered = [...sortable]; const reordered = [...sortable];
const [moved] = reordered.splice(fromIdx, 1); const [moved] = reordered.splice(sFromIdx, 1);
reordered.splice(toIdx, 0, moved); reordered.splice(sToIdx, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]); setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromNumId, position: sToIdx + 1 })
const catIds = reordered.map(c => String(c.id)); .then(res => {
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
try {
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,
@@ -116,33 +127,36 @@
return existing ? { ...existing, ...fresh } : fresh; return existing ? { ...existing, ...fresh } : fresh;
}), }),
]); ]);
} catch (e: any) { })
.catch(async (e: any) => {
catsError = e?.message ?? "Failed to reorder"; catsError = e?.message ?? "Failed to reorder";
await loadCategories(); await loadCategories();
});
}
} }
} }
function onDragStart(e: DragEvent, id: number) { function onDragStart(e: DragEvent, id: string) {
dragId = id; dragStrId = id;
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", id); }
} }
function onDragOver(e: DragEvent, id: number) { function onDragOver(e: DragEvent, id: string) {
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragId === id) return; if (dragStrId === id) return;
dragOverId = id; dragOverStrId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below"; dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
} }
function onDrop(e: DragEvent, id: number) { function onDrop(e: DragEvent, id: string) {
e.preventDefault(); e.preventDefault();
if (dragId !== null && dragId !== id) applyReorder(dragId, id); if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id);
dragId = null; dragOverId = null; dropPosition = null; dragStrId = null; dragOverStrId = null; dropPosition = null;
} }
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; } function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null; }
function focusInput(node: HTMLElement) { node.focus(); } function focusInput(node: HTMLElement) { node.focus(); }
@@ -166,61 +180,58 @@
{#if catsLoading} {#if catsLoading}
<p class="s-empty">Loading folders…</p> <p class="s-empty">Loading folders…</p>
{:else} {:else}
<div class="s-folder-row s-folder-row-static"> <div class="s-folder-list" class:is-dragging={dragStrId !== null}>
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span> {#each orderedAllIds as id}
<span class="s-folder-name s-folder-name-static">Saved</span> {@const isBuiltin = id === "library" || id === "downloaded"}
<span class="s-folder-badge">built-in</span> {@const isCompleted = id === completedId}
<div class="s-folder-actions"> {@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)}
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">Downloaded</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{#if completedCat}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{/if}
<div class="s-folder-divider" aria-hidden="true"></div>
<div class="s-folder-list" class:is-dragging={dragId !== null}>
{#each orderedCatIds.filter(id => id !== completedId) as id}
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
{@const hidden = isHidden(id)} {@const hidden = isHidden(id)}
{#if cat}
{#if isBuiltin || cat}
<div <div
class="s-folder-row" class="s-folder-row"
class:dragging={dragId === cat.id} class:dragging={dragStrId === id}
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"} class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === "above"}
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"} class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === "below"}
ondragover={(e) => onDragOver(e, cat.id)} draggable="true"
ondrop={(e) => onDrop(e, cat.id)} ondragstart={(e) => onDragStart(e, id)}
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }} ondragover={(e) => onDragOver(e, id)}
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }}
ondrop={(e) => onDrop(e, id)}
ondragend={onDragEnd}
> >
{#if isCompleted}
<span class="s-folder-icon">
<CheckSquare size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? "Completed"}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if isBuiltin}
<span class="s-folder-icon">
{#if id === "library"}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{id === "library" ? "Saved" : "Downloaded"}</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if cat}
{#if editingId === cat.id} {#if editingId === cat.id}
<input class="s-input full" bind:value={editingName} <input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }} onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
@@ -228,7 +239,7 @@
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button> <button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else} {:else}
<div class="s-folder-identity" draggable="true" <div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, cat.id)} ondragstart={(e) => onDragStart(e, id)}
ondragend={onDragEnd}> ondragend={onDragEnd}>
<span class="s-folder-icon"> <span class="s-folder-icon">
<FolderSimple size={14} weight="light" /> <FolderSimple size={14} weight="light" />
@@ -257,6 +268,7 @@
</button> </button>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
{/if} {/if}
{/each} {/each}
@@ -314,28 +326,24 @@
.s-folder-row.drop-above::before { top: -1px; } .s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; } .s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-row-static {
cursor: default;
}
.s-folder-icon-static { .s-folder-icon-static {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-faint); color: var(--text-primary);
width: 14px; width: 14px;
} }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-icon { .s-folder-icon {
display: grid; display: grid;
flex-shrink: 0; flex-shrink: 0;
@@ -371,14 +379,6 @@
text-underline-offset: 3px; text-underline-offset: 3px;
} }
.s-folder-name-static {
cursor: default;
color: var(--text-secondary);
}
.s-folder-name-static:hover {
text-decoration: none;
}
.s-folder-actions { .s-folder-actions {
display: flex; display: flex;
@@ -400,12 +400,6 @@
margin-left: 6px; margin-left: 6px;
} }
.s-folder-divider {
height: 1px;
background: var(--border-dim);
margin: 2px 0;
}
.s-btn-icon.active { .s-btn-icon.active {
color: var(--accent, #6c8ef5); color: var(--accent, #6c8ef5);
} }
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { store, updateSettings } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
import { selectPortal } from "@core/actions/selectPortal"; import { selectPortal } from "@core/actions/selectPortal";
import { invoke } from "@tauri-apps/api/core";
interface Props { interface Props {
selectOpen: string | null; selectOpen: string | null;
@@ -12,6 +13,12 @@
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props(); let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props();
let triggerIdleTimeout = $state<HTMLButtonElement>(null!); let triggerIdleTimeout = $state<HTMLButtonElement>(null!);
let serverAdvancedOpen = $state(false);
async function pickServerBinary() {
const picked = await invoke<string | null>("pick_server_binary");
if (picked) updateSettings({ serverBinary: picked });
}
</script> </script>
<div class="s-panel"> <div class="s-panel">
@@ -43,14 +50,70 @@
<div class="s-section"> <div class="s-section">
<p class="s-section-title">Server</p> <p class="s-section-title">Server</p>
<div class="s-section-body"> <div class="s-section-body">
<div class="s-row"> <div class="s-row">
<div class="s-row-info"><span class="s-label">Server URL</span><span class="s-desc">Base URL of your Suwayomi instance</span></div> <div class="s-row-info">
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" /> <span class="s-label">Server URL</span>
<span class="s-desc">Base URL of your Suwayomi instance</span>
</div> </div>
<div class="srv-url-group">
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"}
oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })}
placeholder="http://localhost:4567" spellcheck="false" />
<button
class="srv-adv-btn"
class:open={serverAdvancedOpen}
onclick={() => serverAdvancedOpen = !serverAdvancedOpen}
title="Server launch options"
aria-expanded={serverAdvancedOpen}
>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<label class="s-row"> <label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div> <div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="s-toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="s-toggle-thumb"></span></button> <button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server"
class="s-toggle" class:on={store.settings.autoStartServer}
onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}>
<span class="s-toggle-thumb"></span>
</button>
</label> </label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Suwayomi Web UI</span><span class="s-desc">Enable the built-in Suwayomi web interface alongside Moku</span></div>
<button role="switch" aria-checked={store.settings.suwayomiWebUI ?? false} aria-label="Suwayomi Web UI"
class="s-toggle" class:on={store.settings.suwayomiWebUI ?? false}
onclick={() => updateSettings({ suwayomiWebUI: !(store.settings.suwayomiWebUI ?? false) })}>
<span class="s-toggle-thumb"></span>
</button>
</label>
{#if serverAdvancedOpen}
<div class="srv-adv-panel">
<div class="srv-adv-row">
<div class="s-row-info">
<span class="s-label">Server binary</span>
<span class="s-desc">Path to server executable — leave blank to use bundled</span>
</div>
<div class="srv-file-group">
<input class="s-input srv-path-input" value={store.settings.serverBinary ?? ""}
oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })}
placeholder="auto-detect" spellcheck="false" />
<button class="srv-file-btn" onclick={pickServerBinary} title="Browse">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1.5 4.5h11v7a1 1 0 01-1 1h-9a1 1 0 01-1-1v-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M1.5 4.5l1.8-2.5h3.4l1.3 2.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
{/if}
</div> </div>
</div> </div>
@@ -133,4 +196,70 @@
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); } .s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); } .s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); } .s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
.srv-url-group {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.srv-adv-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-surface);
color: var(--text-faint);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.srv-adv-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
.srv-adv-btn.open { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.srv-adv-btn svg { transition: transform var(--t-base); }
.srv-adv-btn.open svg { transform: rotate(180deg); }
.srv-adv-panel {
border-top: 1px solid var(--border-dim);
background: var(--bg-base);
}
.srv-adv-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px var(--sp-4);
gap: var(--sp-4);
}
.srv-file-group {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.srv-path-input {
width: 160px;
}
.srv-file-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-surface);
color: var(--text-faint);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.srv-file-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
</style> </style>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { store, updateSettings } from "@store/state.svelte"; import { store, updateSettings } from "@store/state.svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import { authSession } from "@core/auth"; import { authSession, loginUI, logout } from "@core/auth";
import { GET_SERVER_SECURITY } from "@api/queries/extensions"; import { GET_SERVER_SECURITY } from "@api/queries/extensions";
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions"; import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
@@ -33,13 +33,18 @@
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15); let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false); let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
function normalizeAuthMode(mode: string): "NONE" | "BASIC_AUTH" | "UI_LOGIN" {
if (mode === "BASIC_AUTH" || mode === "UI_LOGIN" || mode === "NONE") return mode;
return "NONE";
}
function showSaved(key: string) { function showSaved(key: string) {
secSaved = key; secError = null; secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000); setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
} }
$effect(() => { $effect(() => {
if (!secLoaded) { secLoaded = true; loadServerSecurity(); } if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity(); }
}); });
async function loadServerSecurity() { async function loadServerSecurity() {
@@ -53,9 +58,11 @@
flareSolverrAsResponseFallback: boolean; flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY); }}>(GET_SERVER_SECURITY);
const s = res.settings; const s = res.settings;
authMode = store.settings.serverAuthMode ?? "NONE"; const serverMode = normalizeAuthMode(s.authMode);
authUsername = s.authUsername || store.settings.serverAuthUser || ""; if (serverMode !== "UI_LOGIN") authSession.clearTokens();
updateSettings({ serverAuthUser: authUsername }); authMode = serverMode;
authUsername = s.authUsername || "";
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername });
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost; socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion; socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername; socksUsername = s.socksProxyUsername;
@@ -73,32 +80,38 @@
} }
async function saveAuth() { async function saveAuth() {
if ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim())) { if (authMode === "NONE") { await clearAuth(); return; }
if (!authUsername.trim() || !authPassword.trim()) {
secError = "Username and password are required"; return; secError = "Username and password are required"; return;
} }
secLoading = true; secError = null; secLoading = true; secError = null;
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass }; const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
try { try {
const newUser = authMode !== "NONE" ? authUsername.trim() : ""; const newUser = authUsername.trim();
const newPass = authMode !== "NONE" ? authPassword.trim() : ""; const newPass = authPassword.trim();
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass }); authSession.clearTokens();
if (authMode === "UI_LOGIN") { if (authMode === "UI_LOGIN") {
authSession.clearTokens(); await loginUI(newUser, newPass);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" }); updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
} else if (authMode === "BASIC_AUTH") {
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
} else { } else {
authSession.clearTokens(); updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
} }
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
authPassword = ""; authPassword = "";
showSaved("auth"); showSaved("auth");
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? "Failed to save authentication settings";
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg);
if (!authMismatch) {
authSession.clearTokens();
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass }); updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
secError = e?.message ?? "Failed to save authentication settings"; }
secError = authMismatch
? "Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration."
: msg;
} finally { secLoading = false; } } finally { secLoading = false; }
} }
@@ -223,7 +236,7 @@
</button> </button>
{/if} {/if}
<button class="s-btn s-btn-accent" onclick={saveAuth} <button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}> disabled={secLoading || ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"} {secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
</button> </button>
</div> </div>
+2 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import Sidebar from "@shared/chrome/Sidebar.svelte"; import Sidebar from "@shared/chrome/Sidebar.svelte";
import RecentActivity from "@shared/chrome/RecentActivity.svelte";
import Library from "@features/library/components/Library.svelte"; import Library from "@features/library/components/Library.svelte";
import SeriesDetail from "@features/series/components/SeriesDetail.svelte"; import SeriesDetail from "@features/series/components/SeriesDetail.svelte";
import Home from "@features/home/components/Home.svelte"; import Home from "@features/home/components/Home.svelte";
@@ -10,6 +9,7 @@
import Downloads from "@features/downloads/components/Downloads.svelte"; import Downloads from "@features/downloads/components/Downloads.svelte";
import Extensions from "@features/extensions/components/Extensions.svelte"; import Extensions from "@features/extensions/components/Extensions.svelte";
import Tracking from "@features/tracking/components/Tracking.svelte"; import Tracking from "@features/tracking/components/Tracking.svelte";
import Recent from "@features/recent/components/Recent.svelte";
</script> </script>
<div class="frame"> <div class="frame">
@@ -27,7 +27,7 @@
{:else if store.navPage === "search"} {:else if store.navPage === "search"}
<Search /> <Search />
{:else if store.navPage === "history"} {:else if store.navPage === "history"}
<RecentActivity /> <Recent />
{:else if store.navPage === "downloads"} {:else if store.navPage === "downloads"}
<Downloads /> <Downloads />
{:else if store.navPage === "extensions"} {:else if store.navPage === "extensions"}
+1 -1
View File
@@ -7,7 +7,7 @@
{ id: "home", label: "Home", icon: House }, { id: "home", label: "Home", icon: House },
{ id: "library", label: "Library", icon: Books }, { id: "library", label: "Library", icon: Books },
{ id: "search", label: "Search", icon: MagnifyingGlass }, { id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", icon: ClockCounterClockwise }, { id: "history", label: "Recent", icon: ClockCounterClockwise },
{ id: "downloads", label: "Downloads", icon: DownloadSimple }, { id: "downloads", label: "Downloads", icon: DownloadSimple },
{ id: "extensions", label: "Extensions", icon: PuzzlePiece }, { id: "extensions", label: "Extensions", icon: PuzzlePiece },
{ id: "tracking", label: "Tracking", icon: ChartLineUp }, { id: "tracking", label: "Tracking", icon: ChartLineUp },
+2 -2
View File
@@ -92,7 +92,7 @@ export interface Settings {
discordRpc: boolean; discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number; chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean; uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
serverUrl: string; serverBinary: string; autoStartServer: boolean; serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean;
preferredExtensionLang: string; keybinds: Keybinds; preferredExtensionLang: string; keybinds: Keybinds;
idleTimeoutMin?: number; splashCards?: boolean; idleTimeoutMin?: number; splashCards?: boolean;
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number; storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
@@ -143,7 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
discordRpc: false, discordRpc: false,
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25, chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true, uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true, serverUrl: "http://localhost:4567", serverBinary: "", serverBinaryArgs: "", autoStartServer: true, suwayomiWebUI: false,
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true, markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,