mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
47 Commits
v0.9.3
...
bf071dcfc7
| Author | SHA1 | Date | |
|---|---|---|---|
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| bee8117aac | |||
| 0bea9c22cb |
@@ -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 ---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.9.3
|
pkgver=0.9.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -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=(
|
||||||
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
'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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
perSystem =
|
perSystem =
|
||||||
{ system, lib, ... }:
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.9.3";
|
version = "0.9.4";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -179,11 +244,11 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/moku-project/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.9.3
|
tag: v0.9.4
|
||||||
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
|
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: c690eb3cb24e89fec3f4e92f7a4a82d9a465b58f6680a332c1e44f1361ac96af
|
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ stdenv.mkDerivation {
|
|||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-http": "^2.5.8",
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tauri-apps/plugin-store": "~2.4.2",
|
"@tauri-apps/plugin-store": "~2.4.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -3024,14 +3024,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.79.crate",
|
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
|
||||||
"sha256": "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542",
|
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
|
||||||
"dest": "cargo/vendor/openssl-0.10.79"
|
"dest": "cargo/vendor/openssl-0.10.80"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542\", \"files\": {}}",
|
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-0.10.79",
|
"dest": "cargo/vendor/openssl-0.10.80",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3063,14 +3063,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.115.crate",
|
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
|
||||||
"sha256": "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781",
|
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.115"
|
"dest": "cargo/vendor/openssl-sys-0.9.116"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781\", \"files\": {}}",
|
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.115",
|
"dest": "cargo/vendor/openssl-sys-0.9.116",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4688,66 +4688,66 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri/tauri-2.11.1.crate",
|
"url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
|
||||||
"sha256": "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405",
|
"sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
|
||||||
"dest": "cargo/vendor/tauri-2.11.1"
|
"dest": "cargo/vendor/tauri-2.11.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405\", \"files\": {}}",
|
"contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-2.11.1",
|
"dest": "cargo/vendor/tauri-2.11.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.1.crate",
|
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
|
||||||
"sha256": "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007",
|
"sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
|
||||||
"dest": "cargo/vendor/tauri-build-2.6.1"
|
"dest": "cargo/vendor/tauri-build-2.6.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007\", \"files\": {}}",
|
"contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-build-2.6.1",
|
"dest": "cargo/vendor/tauri-build-2.6.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.1.crate",
|
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
|
||||||
"sha256": "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528",
|
"sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
|
||||||
"dest": "cargo/vendor/tauri-codegen-2.6.1"
|
"dest": "cargo/vendor/tauri-codegen-2.6.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528\", \"files\": {}}",
|
"contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-codegen-2.6.1",
|
"dest": "cargo/vendor/tauri-codegen-2.6.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.1.crate",
|
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
|
||||||
"sha256": "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502",
|
"sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
|
||||||
"dest": "cargo/vendor/tauri-macros-2.6.1"
|
"dest": "cargo/vendor/tauri-macros-2.6.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502\", \"files\": {}}",
|
"contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-macros-2.6.1",
|
"dest": "cargo/vendor/tauri-macros-2.6.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.1.crate",
|
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
|
||||||
"sha256": "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee",
|
"sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
|
||||||
"dest": "cargo/vendor/tauri-plugin-2.6.1"
|
"dest": "cargo/vendor/tauri-plugin-2.6.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee\", \"files\": {}}",
|
"contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-plugin-2.6.1",
|
"dest": "cargo/vendor/tauri-plugin-2.6.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4862,40 +4862,40 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.1.crate",
|
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
|
||||||
"sha256": "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc",
|
"sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
|
||||||
"dest": "cargo/vendor/tauri-runtime-2.11.1"
|
"dest": "cargo/vendor/tauri-runtime-2.11.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc\", \"files\": {}}",
|
"contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-runtime-2.11.1",
|
"dest": "cargo/vendor/tauri-runtime-2.11.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.1.crate",
|
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
|
||||||
"sha256": "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0",
|
"sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
|
||||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1"
|
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0\", \"files\": {}}",
|
"contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1",
|
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.1.crate",
|
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
|
||||||
"sha256": "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec",
|
"sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
|
||||||
"dest": "cargo/vendor/tauri-utils-2.9.1"
|
"dest": "cargo/vendor/tauri-utils-2.9.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec\", \"files\": {}}",
|
"contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-utils-2.9.1",
|
"dest": "cargo/vendor/tauri-utils-2.9.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-os':
|
'@tauri-apps/plugin-os':
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2
|
version: 2.3.2
|
||||||
|
'@tauri-apps/plugin-process':
|
||||||
|
specifier: ^2.3.1
|
||||||
|
version: 2.3.1
|
||||||
'@tauri-apps/plugin-shell':
|
'@tauri-apps/plugin-shell':
|
||||||
specifier: ^2.3.5
|
specifier: ^2.3.5
|
||||||
version: 2.3.5
|
version: 2.3.5
|
||||||
@@ -289,6 +292,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-os@2.3.2':
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
|
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-shell@2.3.5':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||||
|
|
||||||
@@ -763,6 +769,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.0
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-shell@2.3.5':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.0
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|||||||
Generated
+21
-21
@@ -2005,7 +2005,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -2379,9 +2379,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.79"
|
version = "0.10.80"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -2410,9 +2410,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.115"
|
version = "0.9.116"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3811,9 +3811,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3862,9 +3862,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-build"
|
name = "tauri-build"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
|
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
@@ -3883,9 +3883,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-codegen"
|
name = "tauri-codegen"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
|
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -3910,9 +3910,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-macros"
|
name = "tauri-macros"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
|
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3924,9 +3924,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin"
|
name = "tauri-plugin"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -4088,9 +4088,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
|
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -4113,9 +4113,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime-wry"
|
name = "tauri-runtime-wry"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
@@ -4139,9 +4139,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.9.1"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
|
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Moku",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:tray:default",
|
"core:tray:default",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor",
|
"core:window:allow-scale-factor",
|
||||||
"process:default",
|
"process:default",
|
||||||
|
"process:allow-exit",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
"http:allow-fetch",
|
"http:allow-fetch",
|
||||||
|
|||||||
@@ -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={}",
|
||||||
|
|||||||
@@ -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,19 +52,95 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_dir_best_effort(path: &std::path::Path) {
|
||||||
|
if path.is_file() {
|
||||||
|
if let Err(e) = std::fs::remove_file(path) {
|
||||||
|
if e.raw_os_error() == Some(32) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if path.is_dir() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
remove_dir_best_effort(&entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let locked = if path.is_file() {
|
||||||
|
std::fs::OpenOptions::new().write(true).open(path).is_err()
|
||||||
|
} else if path.is_dir() {
|
||||||
|
std::fs::read_dir(path).is_err()
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !locked {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
use tauri::Manager;
|
let window = app.get_webview_window("main").ok_or("no main window")?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||||
|
|
||||||
|
window
|
||||||
|
.with_webview(move |_wv| {
|
||||||
|
let _ = tx.send(Ok(()));
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
rx.await.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||||
if cache_dir.exists() {
|
if cache_dir.exists() {
|
||||||
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
wait_until_deletable(&cache_dir, 3);
|
||||||
|
remove_dir_best_effort(&cache_dir);
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +148,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||||
use crate::server::resolve::suwayomi_data_dir;
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
let data_dir = suwayomi_data_dir();
|
let data_dir = suwayomi_data_dir();
|
||||||
for dir in &["cache", "bin/kcef", "cache/kcef"] {
|
for dir in &["cache/kcef", "logs"] {
|
||||||
let p = data_dir.join(dir);
|
let p = data_dir.join(dir);
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
|
remove_dir_best_effort(&p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir in &["downloads/thumbnails"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
remove_dir_best_effort(&p);
|
||||||
|
let _ = std::fs::create_dir_all(&p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -87,10 +169,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
use crate::server::resolve::suwayomi_data_dir;
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
crate::server::kill_tachidesk(&app);
|
crate::server::kill_tachidesk(&app);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
let data_dir = suwayomi_data_dir();
|
||||||
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
|
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
|
||||||
|
|
||||||
|
for entry_name in &targets {
|
||||||
|
let p = data_dir.join(entry_name);
|
||||||
|
if p.exists() {
|
||||||
|
wait_until_deletable(&p, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry_name in &targets {
|
||||||
let p = data_dir.join(entry_name);
|
let p = data_dir.join(entry_name);
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
|||||||
+112
-3
@@ -2,13 +2,81 @@ mod commands;
|
|||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Manager, WindowEvent};
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
Manager, WindowEvent,
|
||||||
|
};
|
||||||
use tauri_plugin_shell::process::CommandChild;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
|
||||||
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
|
const IPC_PORT: u16 = 47823;
|
||||||
|
const HANDSHAKE: &[u8] = b"MOKU:1\n";
|
||||||
|
const FOCUS_CMD: &[u8] = b"focus\n";
|
||||||
|
|
||||||
|
fn do_quit(app: &tauri::AppHandle) {
|
||||||
|
server::kill_tachidesk(app);
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_instance_listener(app: tauri::AppHandle) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
handle_ipc_connection(stream, &app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let Ok(n) = stream.read(&mut buf) else { return };
|
||||||
|
let msg = &buf[..n];
|
||||||
|
|
||||||
|
if !msg.starts_with(HANDSHAKE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd = &msg[HANDSHAKE.len()..];
|
||||||
|
if cmd.starts_with(b"focus") {
|
||||||
|
let _ = stream.write_all(b"ok\n");
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.unminimize();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_existing_instance() -> bool {
|
||||||
|
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
|
||||||
|
|
||||||
|
let mut msg = Vec::new();
|
||||||
|
msg.extend_from_slice(HANDSHAKE);
|
||||||
|
msg.extend_from_slice(FOCUS_CMD);
|
||||||
|
|
||||||
|
if stream.write_all(&msg).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resp = [0u8; 4];
|
||||||
|
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
if signal_existing_instance() {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_discord_rpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
@@ -34,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,
|
||||||
@@ -44,12 +113,52 @@ pub fn run() {
|
|||||||
commands::biometric::windows_hello_authenticate,
|
commands::biometric::windows_hello_authenticate,
|
||||||
commands::biometric::windows_hello_available,
|
commands::biometric::windows_hello_available,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|app| {
|
||||||
|
start_instance_listener(app.handle().clone());
|
||||||
|
|
||||||
|
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
|
||||||
|
let sep = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.tooltip("Moku")
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => do_quit(app),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
server::kill_tachidesk(window.app_handle());
|
server::kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku")
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
+68
-167
@@ -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(
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
log,
|
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
|
||||||
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
|
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
|
||||||
);
|
if java.exists() { Some(java) } else { None }
|
||||||
if java.exists() {
|
|
||||||
Some(java)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_root_args() -> Vec<String> {
|
fn data_root_args() -> Vec<String> {
|
||||||
@@ -74,24 +65,34 @@ 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,
|
||||||
log: &mut Option<std::fs::File>,
|
log: &mut Option<std::fs::File>,
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
do_log(log, &format!("[resolve] binary={:?}", 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,53 +115,30 @@ 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);
|
||||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
|
||||||
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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.9.3",
|
"version": "0.9.4",
|
||||||
"identifier": "io.github.MokuProject.Moku",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+22
-45
@@ -3,9 +3,6 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { defaultWindowIcon } from "@tauri-apps/api/app";
|
|
||||||
import { TrayIcon } from "@tauri-apps/api/tray";
|
|
||||||
import { Menu } from "@tauri-apps/api/menu";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
@@ -48,8 +45,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doQuit() {
|
async function doQuit() {
|
||||||
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
|
if (store.settings.autoStartServer) {
|
||||||
await win.destroy();
|
await Promise.race([
|
||||||
|
invoke("kill_server").catch(() => {}),
|
||||||
|
new Promise(res => setTimeout(res, 2000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await invoke("exit_app");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doHide() {
|
async function doHide() {
|
||||||
@@ -89,6 +91,13 @@
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
downloadStore.poll();
|
||||||
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
return () => clearInterval(dlInterval);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (store.settings.discordRpc) {
|
if (store.settings.discordRpc) {
|
||||||
initRpc();
|
initRpc();
|
||||||
@@ -123,60 +132,28 @@
|
|||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "show",
|
|
||||||
text: "Show Moku",
|
|
||||||
action: async () => {
|
|
||||||
await win.show();
|
|
||||||
await win.setFocus();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quit",
|
|
||||||
text: "Quit",
|
|
||||||
action: doQuit,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await TrayIcon.new({
|
|
||||||
icon: await defaultWindowIcon(),
|
|
||||||
menu,
|
|
||||||
menuOnLeftClick: false,
|
|
||||||
tooltip: "Moku",
|
|
||||||
action: async (e) => {
|
|
||||||
if (e.type === "Click") {
|
|
||||||
await win.show();
|
|
||||||
await win.setFocus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||||
|
|
||||||
|
await initStore();
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await initStore();
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
"download-progress",
|
"download-progress",
|
||||||
e => setActiveDownloads(e.payload),
|
e => setActiveDownloads(e.payload),
|
||||||
);
|
);
|
||||||
|
|
||||||
await downloadStore.poll();
|
|
||||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopProbe();
|
stopProbe();
|
||||||
clearInterval(dlInterval);
|
|
||||||
unlistenResize();
|
unlistenResize();
|
||||||
unlistenScale();
|
unlistenScale();
|
||||||
unlistenDownload();
|
unlistenDownload();
|
||||||
@@ -215,7 +192,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div id="app-shell" class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-2
@@ -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 ?? "";
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const GET_SOURCES = `
|
|||||||
sources {
|
sources {
|
||||||
nodes {
|
nodes {
|
||||||
id name lang displayName iconUrl isNsfw
|
id name lang displayName iconUrl isNsfw
|
||||||
isConfigurable supportsLatest baseUrl
|
isConfigurable supportsLatest
|
||||||
extension { pkgName }
|
extension { pkgName }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export const GET_MIGRATABLE_SOURCES = `
|
|||||||
nodes {
|
nodes {
|
||||||
sourceId
|
sourceId
|
||||||
source {
|
source {
|
||||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl
|
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export const LIBRARY_UPDATE_STATUS = `
|
|||||||
manga { id title thumbnailUrl unreadCount }
|
manga { id title thumbnailUrl unreadCount }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastUpdateTimestamp {
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
/**
|
|
||||||
* {@attach selectPortal(triggerEl)}
|
|
||||||
*
|
|
||||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
|
||||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
|
||||||
*
|
|
||||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
|
||||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
|
||||||
*/
|
|
||||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||||
return (menuEl: HTMLElement) => {
|
return (menuEl: HTMLElement) => {
|
||||||
// Position & move to body
|
|
||||||
function position() {
|
function position() {
|
||||||
|
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||||
const r = triggerEl.getBoundingClientRect();
|
const r = triggerEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
const top = r.bottom / zoom + 4;
|
||||||
|
const right = r.right / zoom;
|
||||||
|
const width = menuEl.offsetWidth;
|
||||||
|
const left = Math.max(8, right - width);
|
||||||
|
|
||||||
menuEl.style.position = "fixed";
|
menuEl.style.position = "fixed";
|
||||||
menuEl.style.top = `${r.bottom + 4}px`;
|
menuEl.style.top = `${top}px`;
|
||||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
menuEl.style.left = `${left}px`;
|
||||||
// clamp to viewport left edge
|
|
||||||
const left = parseFloat(menuEl.style.left);
|
|
||||||
if (left < 8) menuEl.style.left = "8px";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menuEl.style.visibility = "hidden";
|
||||||
document.body.appendChild(menuEl);
|
document.body.appendChild(menuEl);
|
||||||
triggerEl.__selectMenuEl = menuEl;
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
position();
|
|
||||||
|
|
||||||
// Reposition on scroll / resize while open
|
requestAnimationFrame(() => {
|
||||||
|
position();
|
||||||
|
menuEl.style.visibility = "";
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("scroll", position, true);
|
window.addEventListener("scroll", position, true);
|
||||||
window.addEventListener("resize", position);
|
window.addEventListener("resize", position);
|
||||||
|
|
||||||
|
|||||||
+494
-16
@@ -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`, {
|
||||||
|
|||||||
Vendored
+11
-5
@@ -1,12 +1,13 @@
|
|||||||
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>>();
|
||||||
const MAX_CONCURRENT = 6;
|
const MAX_CONCURRENT = 6;
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let drainScheduled = false;
|
let drainScheduled = false;
|
||||||
|
let clearing = false;
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -17,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") {
|
||||||
@@ -32,9 +33,12 @@ 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 blobUrl = URL.createObjectURL(await res.blob());
|
const blob = await res.blob();
|
||||||
|
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
cache.set(url, blobUrl);
|
cache.set(url, blobUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
}
|
}
|
||||||
@@ -121,8 +125,10 @@ export function cancelQueuedFetches(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
|
clearing = true;
|
||||||
cancelQueuedFetches();
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
|
clearing = false;
|
||||||
}
|
}
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
interface MemEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryCache<T> {
|
||||||
|
readonly #cap: number;
|
||||||
|
readonly #ttl: number;
|
||||||
|
readonly #map = new Map<string, MemEntry<T>>();
|
||||||
|
|
||||||
|
constructor(capacity: number, ttlMs: number) {
|
||||||
|
this.#cap = capacity;
|
||||||
|
this.#ttl = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | undefined {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||||
|
this.#map.delete(key);
|
||||||
|
this.#map.set(key, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: T): void {
|
||||||
|
if (this.#map.has(key)) this.#map.delete(key);
|
||||||
|
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||||
|
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string): boolean {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void { this.#map.delete(key); }
|
||||||
|
|
||||||
|
clear(): void { this.#map.clear(); }
|
||||||
|
|
||||||
|
get size(): number { return this.#map.size; }
|
||||||
|
}
|
||||||
Vendored
+1
-4
@@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function preloadImage(url: string, useBlob: boolean): void {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (useBlob) {
|
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||||
preloadBlobUrls([url], 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+74
-7
@@ -1,10 +1,13 @@
|
|||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
|
fetcher?: () => Promise<T>;
|
||||||
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = new Map<string, Entry<unknown>>();
|
const store = new Map<string, Entry<unknown>>();
|
||||||
const subs = new Map<string, Set<() => void>>();
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
const keyToGroups = new Map<string, Set<string>>();
|
||||||
const groups = new Map<string, Set<string>>();
|
const groups = new Map<string, Set<string>>();
|
||||||
|
|
||||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
|
|||||||
for (const tag of Array.isArray(group) ? group : [group]) {
|
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
groups.get(tag)!.add(key);
|
groups.get(tag)!.add(key);
|
||||||
|
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||||
|
keyToGroups.get(key)!.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterKey(key: string) {
|
||||||
|
const tags = keyToGroups.get(key);
|
||||||
|
if (tags) {
|
||||||
|
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||||
|
keyToGroups.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +40,20 @@ export const cache = {
|
|||||||
if (err?.name !== "AbortError") store.delete(key);
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}) as Promise<T>;
|
}) as Promise<T>;
|
||||||
store.set(key, { promise, fetchedAt: Date.now() });
|
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||||
registerGroups(key, group);
|
registerGroups(key, group);
|
||||||
promise.then(() => notify(key)).catch(() => {});
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
set<T>(key: string, value: T, group?: string | string[]) {
|
set<T>(key: string, value: T, group?: string | string[]) {
|
||||||
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
store.set(key, {
|
||||||
|
promise: Promise.resolve(value),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
fetcher: existing?.fetcher,
|
||||||
|
ttl: existing?.ttl,
|
||||||
|
});
|
||||||
registerGroups(key, group);
|
registerGroups(key, group);
|
||||||
notify(key);
|
notify(key);
|
||||||
},
|
},
|
||||||
@@ -43,10 +62,38 @@ export const cache = {
|
|||||||
const existing = store.get(key) as Entry<T> | undefined;
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const next = existing.promise.then(fn);
|
const next = existing.promise.then(fn);
|
||||||
store.set(key, { promise: next, fetchedAt: Date.now() });
|
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||||
next.then(() => notify(key)).catch(() => {});
|
next.then(() => notify(key)).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refresh<T>(key: string): Promise<T> | undefined {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing?.fetcher) return undefined;
|
||||||
|
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshGroup(tag: string): void {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of [...keys]) {
|
||||||
|
const existing = store.get(key);
|
||||||
|
if (existing?.fetcher) {
|
||||||
|
const promise = existing.fetcher().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
has(key: string): boolean { return store.has(key); },
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
ageOf(key: string): number | undefined {
|
ageOf(key: string): number | undefined {
|
||||||
@@ -54,18 +101,35 @@ export const cache = {
|
|||||||
return e ? Date.now() - e.fetchedAt : undefined;
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
clear(key: string) { store.delete(key); notify(key); },
|
isStale(key: string): boolean {
|
||||||
|
const e = store.get(key);
|
||||||
|
if (!e) return true;
|
||||||
|
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(key: string) {
|
||||||
|
unregisterKey(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
clearGroup(tag: string) {
|
clearGroup(tag: string) {
|
||||||
const keys = groups.get(tag);
|
const keys = groups.get(tag);
|
||||||
if (!keys) return;
|
if (!keys) return;
|
||||||
for (const key of keys) { store.delete(key); notify(key); }
|
for (const key of [...keys]) {
|
||||||
|
keyToGroups.get(key)?.delete(tag);
|
||||||
|
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
}
|
||||||
groups.delete(tag);
|
groups.delete(tag);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
const allKeys = [...store.keys()];
|
const allKeys = [...store.keys()];
|
||||||
store.clear(); groups.clear();
|
store.clear();
|
||||||
|
groups.clear();
|
||||||
|
keyToGroups.clear();
|
||||||
allKeys.forEach(notify);
|
allKeys.forEach(notify);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,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",
|
||||||
@@ -161,7 +226,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||||
cache.clear(CACHE_KEYS.MANGA(mangaId));
|
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
|
||||||
|
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||||
|
|
||||||
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
cache.clear(CACHE_KEYS.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}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap } from "phosphor-svelte";
|
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { resolvedCover } from "@core/cover/coverResolver";
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
@@ -29,14 +29,24 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
|
|
||||||
|
type ContentFilter = "unread" | "downloaded";
|
||||||
|
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
||||||
|
let filterOpen = $state(false);
|
||||||
|
|
||||||
|
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
||||||
|
|
||||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||||
|
|
||||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||||
const filtered = $derived(
|
|
||||||
search.trim()
|
const filtered = $derived((() => {
|
||||||
? allManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase()))
|
let items = allManga;
|
||||||
: allManga
|
const q = search.trim().toLowerCase();
|
||||||
);
|
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||||
|
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
|
||||||
|
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
|
||||||
|
return items;
|
||||||
|
})());
|
||||||
|
|
||||||
let sourceNodes: SourceNode[] = $state([]);
|
let sourceNodes: SourceNode[] = $state([]);
|
||||||
|
|
||||||
@@ -56,6 +66,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleFilter(f: ContentFilter) {
|
||||||
|
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
activeFilters = {};
|
||||||
|
}
|
||||||
|
|
||||||
function openMigrate(group: SourceLibrary) {
|
function openMigrate(group: SourceLibrary) {
|
||||||
const node = sourceNodes.find(s => s.id === group.sourceId);
|
const node = sourceNodes.find(s => s.id === group.sourceId);
|
||||||
migrateTarget = {
|
migrateTarget = {
|
||||||
@@ -65,6 +83,20 @@
|
|||||||
manga: group.manga,
|
manga: group.manga,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!filterOpen) return;
|
||||||
|
function onOutside(e: MouseEvent) {
|
||||||
|
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
|
||||||
|
}
|
||||||
|
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||||
|
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const CONTENT_FILTERS: [ContentFilter, string][] = [
|
||||||
|
["unread", "Unread"],
|
||||||
|
["downloaded", "Downloaded"],
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
@@ -80,18 +112,57 @@
|
|||||||
<span class="title">{extensionName}</span>
|
<span class="title">{extensionName}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
<span class="count-badge">{allManga.length}</span>
|
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-wrap">
|
||||||
|
<button
|
||||||
|
class="filter-btn"
|
||||||
|
class:filter-btn-active={hasActiveFilters}
|
||||||
|
title="Filter"
|
||||||
|
onclick={() => filterOpen = !filterOpen}
|
||||||
|
>
|
||||||
|
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||||
|
</button>
|
||||||
|
{#if filterOpen}
|
||||||
|
<div class="filter-panel" role="menu">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-heading">Filter</span>
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<p class="panel-label">Content</p>
|
||||||
|
{#each CONTENT_FILTERS as [f, label]}
|
||||||
|
<button
|
||||||
|
class="panel-item"
|
||||||
|
class:panel-item-active={activeFilters[f]}
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => toggleFilter(f)}
|
||||||
|
>
|
||||||
|
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
|
||||||
|
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if sources.length > 0}
|
{#if sources.length > 0}
|
||||||
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
||||||
<GearSix size={14} weight="bold" />
|
<GearSix size={14} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -181,6 +252,7 @@
|
|||||||
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
|
||||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
|
|
||||||
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
@@ -191,12 +263,31 @@
|
|||||||
|
|
||||||
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; margin-left: auto; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.filter-wrap { position: relative; }
|
||||||
|
.filter-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
|
||||||
|
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||||
|
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||||
|
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||||
|
.panel-clear-btn:hover { color: var(--color-error); }
|
||||||
|
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||||
|
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||||
|
.panel-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||||
|
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||||
|
.panel-item-active:hover { background: var(--accent-dim); }
|
||||||
|
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
|
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
|
||||||
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
@@ -237,4 +328,6 @@
|
|||||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
@@ -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,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch } from "phosphor-svelte";
|
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import type { StripChapter } from "../lib/scrollHandler";
|
import type { StripChapter } from "../lib/scrollHandler";
|
||||||
@@ -20,6 +19,7 @@
|
|||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
chapterEpoch: number;
|
chapterEpoch: number;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
|
tapToToggleBar, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
|
||||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -215,19 +215,33 @@
|
|||||||
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let midScrollActive = $state(false);
|
let midScrollActive = $state(false);
|
||||||
let midScrollOriginY = 0;
|
let midScrollOriginY = $state(0);
|
||||||
|
let midScrollOriginX = $state(0);
|
||||||
|
let midScrollCurrentY = 0;
|
||||||
let midScrollRaf: number | null = null;
|
let midScrollRaf: number | null = null;
|
||||||
|
|
||||||
function startMidScroll(originY: number) {
|
// Speed level 0-5 for the indicator bar
|
||||||
|
const midScrollSpeedLevel = $derived.by(() => {
|
||||||
|
if (!midScrollActive) return 0;
|
||||||
|
// recomputes when midScrollOriginY changes; actual dy read in RAF so this is just for display
|
||||||
|
return 0; // will be updated imperatively
|
||||||
|
});
|
||||||
|
let midScrollDisplayLevel = $state(0);
|
||||||
|
|
||||||
|
function startMidScroll(originY: number, originX: number) {
|
||||||
midScrollActive = true;
|
midScrollActive = true;
|
||||||
midScrollOriginY = originY;
|
midScrollOriginY = originY;
|
||||||
|
midScrollOriginX = originX;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!midScrollActive || !containerEl) return;
|
if (!midScrollActive || !containerEl) return;
|
||||||
const dy = (window as any)._midScrollCurrentY - midScrollOriginY;
|
const dy = midScrollCurrentY - midScrollOriginY;
|
||||||
const deadZone = 24;
|
const deadZone = 24;
|
||||||
const speed = Math.sign(dy) * Math.max(0, Math.abs(dy) - deadZone) * 0.12;
|
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
||||||
|
const speed = Math.sign(dy) * excess * 0.12;
|
||||||
containerEl.scrollTop += speed;
|
containerEl.scrollTop += speed;
|
||||||
|
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
midScrollRaf = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
midScrollRaf = requestAnimationFrame(tick);
|
||||||
@@ -235,6 +249,7 @@
|
|||||||
|
|
||||||
function stopMidScroll() {
|
function stopMidScroll() {
|
||||||
midScrollActive = false;
|
midScrollActive = false;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +291,11 @@
|
|||||||
if ((e.target as Element).closest(".bar")) return;
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
if (e.button === 1 && style === "longstrip") {
|
if (e.button === 1 && style === "longstrip") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (midScrollActive) { stopMidScroll(); } else { startMidScroll(e.clientY); }
|
if (midScrollActive) { stopMidScroll(); } else {
|
||||||
|
// pause regular auto-scroll while mid-scroll is active
|
||||||
|
store.settings.autoScroll = false;
|
||||||
|
startMidScroll(e.clientY, e.clientX);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
@@ -299,7 +318,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseMove(e: MouseEvent) {
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
(window as any)._midScrollCurrentY = e.clientY;
|
midScrollCurrentY = e.clientY;
|
||||||
if (stripDragging) {
|
if (stripDragging) {
|
||||||
const dy = e.clientY - stripDragStartY;
|
const dy = e.clientY - stripDragStartY;
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
@@ -404,10 +423,6 @@
|
|||||||
stopMidScroll();
|
stopMidScroll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
(window as any)._midScrollCurrentY = 0;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -425,15 +440,33 @@
|
|||||||
onmousedown={onInspectMouseDown}
|
onmousedown={onInspectMouseDown}
|
||||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||||
style:cursor={midScrollActive ? "none" : style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
||||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; } }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; return; }
|
||||||
|
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if midScrollActive}
|
{#if midScrollActive}
|
||||||
<div class="midscroll-cursor" style="top:{midScrollOriginY}px"></div>
|
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
||||||
|
<div class="midscroll-segments">
|
||||||
|
{#each [5,4,3,2,1] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
<div class="midscroll-origin-dot"></div>
|
||||||
|
{#each [1,2,3,4,5] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true"><svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg></div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
@@ -469,8 +502,8 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="strip-placeholder page-loader" aria-hidden="true">
|
<div class="strip-placeholder" aria-hidden="true">
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -481,7 +514,7 @@
|
|||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">
|
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
</div>
|
</div>
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
|
||||||
@@ -495,7 +528,7 @@
|
|||||||
{#each currentGroup as pg, i (pg)}
|
{#each currentGroup as pg, i (pg)}
|
||||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||||
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
|
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
</div>
|
</div>
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||||
@@ -503,7 +536,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -511,7 +548,7 @@
|
|||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">
|
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
</div>
|
</div>
|
||||||
{:then src}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||||
@@ -523,7 +560,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
.viewer:focus { outline: none; }
|
.viewer:focus { outline: none; }
|
||||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||||
@@ -545,29 +582,51 @@
|
|||||||
max-width: var(--effective-width, 100%);
|
max-width: var(--effective-width, 100%);
|
||||||
aspect-ratio: var(--aspect, 0.667);
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-loader {
|
.page-loader {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-loader-single {
|
.page-loader-single {
|
||||||
width: min(100%, var(--effective-width, 100%));
|
width: min(100%, var(--effective-width, 100%));
|
||||||
max-width: var(--effective-width, 100%);
|
max-width: var(--effective-width, 100%);
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong);
|
||||||
|
stroke-width: 0.8;
|
||||||
|
fill: none;
|
||||||
|
stroke-dasharray: 400;
|
||||||
|
stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||||
:global(.strip-gap) { margin-bottom: 8px; }
|
:global(.strip-gap) { margin-bottom: 8px; }
|
||||||
|
|
||||||
@@ -579,34 +638,73 @@
|
|||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
|
||||||
.midscroll-cursor {
|
.midscroll-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translateY(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.midscroll-bar-right { right: 8px; }
|
||||||
|
.midscroll-bar-left { left: 8px; }
|
||||||
|
|
||||||
|
.midscroll-segments {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.midscroll-origin-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--accent-fg);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.midscroll-seg {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
transition: background 0.06s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.midscroll-seg-lit {
|
||||||
|
background: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.midscroll-stop {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
display: flex;
|
||||||
border: 2px solid var(--accent-fg);
|
align-items: center;
|
||||||
background: transparent;
|
justify-content: center;
|
||||||
pointer-events: none;
|
border-radius: var(--radius-sm);
|
||||||
z-index: 100;
|
border: 1px solid var(--border-dim);
|
||||||
opacity: 0.85;
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.midscroll-cursor::before,
|
.midscroll-stop:hover {
|
||||||
.midscroll-cursor::after {
|
color: var(--text-primary);
|
||||||
content: "";
|
background: var(--bg-overlay);
|
||||||
position: absolute;
|
border-color: var(--border-strong);
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border-left: 5px solid transparent;
|
|
||||||
border-right: 5px solid transparent;
|
|
||||||
}
|
|
||||||
.midscroll-cursor::before {
|
|
||||||
top: -10px;
|
|
||||||
border-bottom: 6px solid var(--accent-fg);
|
|
||||||
}
|
|
||||||
.midscroll-cursor::after {
|
|
||||||
bottom: -10px;
|
|
||||||
border-top: 6px solid var(--accent-fg);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -593,6 +593,7 @@
|
|||||||
fadingOut={readerState.fadingOut}
|
fadingOut={readerState.fadingOut}
|
||||||
{tapToToggleBar}
|
{tapToToggleBar}
|
||||||
{pinchZoomEnabled}
|
{pinchZoomEnabled}
|
||||||
|
{barPosition}
|
||||||
onGetZoom={() => zoom}
|
onGetZoom={() => zoom}
|
||||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||||
|
|||||||
@@ -118,7 +118,6 @@
|
|||||||
class:bar-left={barPosition === "left"}
|
class:bar-left={barPosition === "left"}
|
||||||
class:bar-right={barPosition === "right"}
|
class:bar-right={barPosition === "right"}
|
||||||
class:hidden={!uiVisible}
|
class:hidden={!uiVisible}
|
||||||
data-tauri-drag-region={barPosition === "top" ? true : undefined}
|
|
||||||
>
|
>
|
||||||
<div class="bar-start">
|
<div class="bar-start">
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
@@ -177,7 +176,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isVertical}
|
{#if !isVertical}
|
||||||
<span class="bar-sep"></span>
|
<span class="bar-sep" data-tauri-drag-region></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,6 +186,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<div class="bar-drag-gap" data-tauri-drag-region></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="bar-end">
|
<div class="bar-end">
|
||||||
<div class="zoom-wrap">
|
<div class="zoom-wrap">
|
||||||
<div class="zoom-inline">
|
<div class="zoom-inline">
|
||||||
@@ -393,12 +396,15 @@
|
|||||||
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
||||||
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
|
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
|
||||||
|
.bar-drag-gap:active { cursor: grabbing; }
|
||||||
|
|
||||||
.bar-start, .bar-end {
|
.bar-start, .bar-end {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
.bar-top .bar-start { flex: 1; overflow: hidden; }
|
.bar-top .bar-start { overflow: hidden; }
|
||||||
.bar-left .bar-start,
|
.bar-left .bar-start,
|
||||||
.bar-left .bar-end,
|
.bar-left .bar-end,
|
||||||
.bar-right .bar-start,
|
.bar-right .bar-start,
|
||||||
|
|||||||
+90
-198
@@ -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>
|
||||||
|
|
||||||
@@ -83,7 +146,7 @@
|
|||||||
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
|
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
|
||||||
<div class="s-seg">
|
<div class="s-seg">
|
||||||
{#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]}
|
{#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]}
|
||||||
<button class="s-seg-btn" class:active={( store.settings.closeAction ?? "ask") === v} onclick={() => updateSettings({ closeAction: v as "ask" | "tray" | "quit" })}>{l}</button>
|
<button class="s-seg-btn" class:active={(store.settings.closeAction ?? "ask") === v} onclick={() => updateSettings({ closeAction: v as "ask" | "tray" | "quit" })}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</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>
|
||||||
|
|||||||
@@ -13,14 +13,15 @@
|
|||||||
import type { BackupEntry } from "@core/persistence/persist";
|
import type { BackupEntry } from "@core/persistence/persist";
|
||||||
import { DEFAULT_SETTINGS } from "@types/settings";
|
import { DEFAULT_SETTINGS } from "@types/settings";
|
||||||
import { DEFAULT_READING_STATS } from "@types/history";
|
import { DEFAULT_READING_STATS } from "@types/history";
|
||||||
|
import { clearBlobCache } from "@core/cache/imageCache";
|
||||||
|
import { clearPageCache } from "@core/cache/pageCache";
|
||||||
|
import { cache as queryCache } from "@core/cache/queryCache";
|
||||||
|
|
||||||
type ResetState = "idle" | "busy" | "done" | "error";
|
type ResetState = "idle" | "busy" | "done" | "error";
|
||||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
||||||
|
|
||||||
let resetItems = $state<ResetItem[]>([
|
let resetItems = $state<ResetItem[]>([
|
||||||
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false },
|
{ key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false },
|
||||||
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
|
|
||||||
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false },
|
|
||||||
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
|
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
|
||||||
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
|
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
|
||||||
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
|
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
|
||||||
@@ -73,19 +74,24 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearAllCaches(): Promise<void> {
|
||||||
|
clearBlobCache();
|
||||||
|
clearPageCache();
|
||||||
|
queryCache.clearAll();
|
||||||
|
await Promise.all([
|
||||||
|
invoke("clear_moku_cache"),
|
||||||
|
invoke("clear_suwayomi_cache"),
|
||||||
|
gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async function runReset(key: string) {
|
async function runReset(key: string) {
|
||||||
confirming = null;
|
confirming = null;
|
||||||
patchReset(key, { state: "busy", error: null });
|
patchReset(key, { state: "busy", error: null });
|
||||||
try {
|
try {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "moku-cache":
|
case "all-cache":
|
||||||
await invoke("clear_moku_cache");
|
await clearAllCaches();
|
||||||
break;
|
|
||||||
case "suwayomi-cache":
|
|
||||||
await invoke("clear_suwayomi_cache");
|
|
||||||
break;
|
|
||||||
case "server-cache":
|
|
||||||
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
|
|
||||||
break;
|
break;
|
||||||
case "reading-history":
|
case "reading-history":
|
||||||
store.clearHistory();
|
store.clearHistory();
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
const { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const os = platform();
|
const os = platform();
|
||||||
const isMac = os === "macos";
|
const isMac = os === "macos";
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
|
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
|||||||
+63
-40
@@ -4,7 +4,8 @@ import { trackingState } from "@features/tracking/store/tracki
|
|||||||
import { loadAllStores } from "@core/persistence/persist";
|
import { loadAllStores } from "@core/persistence/persist";
|
||||||
import { notifyReauthSuccess } from "@api/client";
|
import { notifyReauthSuccess } from "@api/client";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40;
|
const MAX_ATTEMPTS = 15;
|
||||||
|
const BG_MAX_ATTEMPTS = 60;
|
||||||
|
|
||||||
export const boot = $state({
|
export const boot = $state({
|
||||||
serverProbeOk: false,
|
serverProbeOk: false,
|
||||||
@@ -26,6 +27,44 @@ export async function initStore() {
|
|||||||
store.hydrate(saved);
|
store.hydrate(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleProbeSuccess(gen: number) {
|
||||||
|
if (gen !== probeGeneration) return;
|
||||||
|
boot.serverProbeOk = true;
|
||||||
|
boot.failed = false;
|
||||||
|
boot.skipped = false;
|
||||||
|
trackingState.bootSync().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthRequired(gen: number) {
|
||||||
|
if (gen !== probeGeneration) return;
|
||||||
|
boot.serverProbeOk = true;
|
||||||
|
boot.failed = false;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
if (user && pass) {
|
||||||
|
loginBasic(user, pass)
|
||||||
|
.then(() => { if (gen === probeGeneration) trackingState.bootSync().catch(() => {}); })
|
||||||
|
.catch(() => {
|
||||||
|
if (gen !== probeGeneration) return;
|
||||||
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
boot.loginRequired = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
boot.loginRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "UI_LOGIN") {
|
||||||
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
boot.loginRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackingState.bootSync().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export function startProbe() {
|
export function startProbe() {
|
||||||
const gen = ++probeGeneration;
|
const gen = ++probeGeneration;
|
||||||
boot.failed = false;
|
boot.failed = false;
|
||||||
@@ -36,51 +75,36 @@ export function startProbe() {
|
|||||||
async function probe() {
|
async function probe() {
|
||||||
if (gen !== probeGeneration) return;
|
if (gen !== probeGeneration) return;
|
||||||
tries++;
|
tries++;
|
||||||
|
|
||||||
const result = await probeServer();
|
const result = await probeServer();
|
||||||
if (gen !== probeGeneration) return;
|
if (gen !== probeGeneration) return;
|
||||||
|
|
||||||
if (result === "ok") {
|
if (result === "ok") { handleProbeSuccess(gen); return; }
|
||||||
boot.serverProbeOk = true;
|
if (result === "auth_required") { handleAuthRequired(gen); return; }
|
||||||
trackingState.bootSync().catch(() => {});
|
if (tries >= MAX_ATTEMPTS) { boot.failed = true; startBackgroundProbe(gen); return; }
|
||||||
return;
|
|
||||||
|
setTimeout(probe, Math.min(300 + tries * 150, 1500));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === "auth_required") {
|
setTimeout(probe, 100);
|
||||||
boot.serverProbeOk = true;
|
}
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
|
|
||||||
if (mode === "BASIC_AUTH") {
|
function startBackgroundProbe(gen: number) {
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
let bgTries = 0;
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (user && pass) {
|
async function bgProbe() {
|
||||||
try {
|
|
||||||
await loginBasic(user, pass);
|
|
||||||
if (gen !== probeGeneration) return;
|
if (gen !== probeGeneration) return;
|
||||||
trackingState.bootSync().catch(() => {});
|
bgTries++;
|
||||||
return;
|
const result = await probeServer();
|
||||||
} catch {}
|
if (gen !== probeGeneration) return;
|
||||||
}
|
|
||||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
if (result === "ok") { handleProbeSuccess(gen); return; }
|
||||||
boot.loginRequired = true;
|
if (result === "auth_required") { handleAuthRequired(gen); return; }
|
||||||
return;
|
if (bgTries >= BG_MAX_ATTEMPTS) return;
|
||||||
|
|
||||||
|
setTimeout(bgProbe, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "UI_LOGIN") {
|
setTimeout(bgProbe, 2000);
|
||||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
|
||||||
boot.loginRequired = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackingState.bootSync().catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
|
||||||
setTimeout(probe, Math.min(750 + tries * 250, 3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(probe, 2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopProbe() {
|
export function stopProbe() {
|
||||||
@@ -94,7 +118,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
|
|||||||
}
|
}
|
||||||
boot.loginBusy = true;
|
boot.loginBusy = true;
|
||||||
boot.loginError = null;
|
boot.loginError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
if (mode === "UI_LOGIN") {
|
if (mode === "UI_LOGIN") {
|
||||||
@@ -102,7 +125,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
|
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.sessionExpired = false;
|
boot.sessionExpired = false;
|
||||||
boot.skipped = false;
|
boot.skipped = false;
|
||||||
@@ -128,10 +150,11 @@ export function retryBoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bypassBoot(onReady: () => void) {
|
export function bypassBoot(onReady: () => void) {
|
||||||
probeGeneration++;
|
const gen = probeGeneration;
|
||||||
boot.serverProbeOk = true;
|
boot.serverProbeOk = true;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.sessionExpired = false;
|
boot.sessionExpired = false;
|
||||||
boot.skipped = true;
|
boot.skipped = true;
|
||||||
onReady();
|
onReady();
|
||||||
|
startBackgroundProbe(gen);
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user