mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da788e90ba | |||
| 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 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bee8117aac | |||
| 0bea9c22cb | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 |
@@ -1,11 +1,15 @@
|
||||
# --- Build Artifacts ---
|
||||
node_modules/
|
||||
suwayomi-raw/
|
||||
suwayomi-windows.zip
|
||||
suwayomi.zip
|
||||
dist/
|
||||
dist-tauri/
|
||||
target/
|
||||
bin/
|
||||
out/
|
||||
|
||||
|
||||
# --- Nix ---
|
||||
.direnv/
|
||||
result
|
||||
@@ -32,6 +36,7 @@ yarn-error.log*
|
||||
|
||||
# --- Tauri specific ---
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/gen/
|
||||
|
||||
# --- Flatpak build artifacts ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.9.2
|
||||
pkgver=0.9.4
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
@@ -13,27 +13,46 @@ depends=(
|
||||
)
|
||||
makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
'nodejs'
|
||||
'pnpm'
|
||||
)
|
||||
optdepends=(
|
||||
'discord: Discord rich presence'
|
||||
)
|
||||
options=('!strip')
|
||||
source=(
|
||||
"$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"
|
||||
)
|
||||
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||
sha256sums=(
|
||||
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||
)
|
||||
b2sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd "Moku-$pkgver"
|
||||
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() {
|
||||
cd "Moku-$pkgver"
|
||||
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 \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -52,7 +71,7 @@ package() {
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -68,14 +87,14 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||
fi
|
||||
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||
"$DATA_DIR/server.conf"
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||
"$DATA_DIR/server.conf"
|
||||
|
||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
@@ -87,12 +106,12 @@ export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
exec java \
|
||||
-Djava.awt.headless=true \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
-Dsun.awt.disablegui=true \
|
||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
-Djava.awt.headless=true \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
-Dsun.awt.disablegui=true \
|
||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
LAUNCHER
|
||||
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
@@ -105,6 +124,6 @@ LAUNCHER
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 LICENSE \
|
||||
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
perSystem =
|
||||
{ system, lib, ... }:
|
||||
let
|
||||
version = "0.9.2";
|
||||
version = "0.9.4";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -128,6 +128,7 @@
|
||||
export NO_STRIP=true
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
|
||||
@@ -32,6 +32,77 @@ build-options:
|
||||
CARGO_HOME: /run/build/moku/cargo
|
||||
|
||||
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
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
@@ -52,9 +123,6 @@ modules:
|
||||
- type: inline
|
||||
dest-filename: catch_abort.c
|
||||
contents: |
|
||||
// Linux only:
|
||||
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <dlfcn.h>
|
||||
@@ -117,19 +185,16 @@ modules:
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Seed conf on first run
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||
fi
|
||||
|
||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||
"$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\.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"
|
||||
@@ -166,10 +231,10 @@ modules:
|
||||
CARGO_HOME: /run/build/moku/cargo
|
||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||
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:
|
||||
- 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 -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
|
||||
@@ -179,11 +244,11 @@ modules:
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.9.2
|
||||
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
|
||||
tag: v0.9.4
|
||||
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086
|
||||
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
inherit version src;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
||||
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "~2.4.2",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
+188
-266
@@ -265,6 +265,19 @@
|
||||
"dest": "cargo/vendor/brotli-decompressor-5.0.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/bs58/bs58-0.5.1.crate",
|
||||
"sha256": "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4",
|
||||
"dest": "cargo/vendor/bs58-0.5.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/bs58-0.5.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -398,14 +411,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/cc/cc-1.2.61.crate",
|
||||
"sha256": "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d",
|
||||
"dest": "cargo/vendor/cc-1.2.61"
|
||||
"url": "https://static.crates.io/crates/cc/cc-1.2.62.crate",
|
||||
"sha256": "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98",
|
||||
"dest": "cargo/vendor/cc-1.2.62"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/cc-1.2.61",
|
||||
"contents": "{\"package\": \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/cc-1.2.62",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -1750,14 +1763,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/h2/h2-0.4.13.crate",
|
||||
"sha256": "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54",
|
||||
"dest": "cargo/vendor/h2-0.4.13"
|
||||
"url": "https://static.crates.io/crates/h2/h2-0.4.14.crate",
|
||||
"sha256": "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733",
|
||||
"dest": "cargo/vendor/h2-0.4.14"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/h2-0.4.13",
|
||||
"contents": "{\"package\": \"171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/h2-0.4.14",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -1789,14 +1802,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.0.crate",
|
||||
"sha256": "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.0"
|
||||
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate",
|
||||
"sha256": "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.0",
|
||||
"contents": "{\"package\": \"ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/hashbrown-0.17.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -2189,19 +2202,6 @@
|
||||
"dest": "cargo/vendor/ipnet-2.12.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
|
||||
"sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
|
||||
"dest": "cargo/vendor/iri-string-0.7.12"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/iri-string-0.7.12",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -2322,14 +2322,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.97.crate",
|
||||
"sha256": "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf",
|
||||
"dest": "cargo/vendor/js-sys-0.3.97"
|
||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.98.crate",
|
||||
"sha256": "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08",
|
||||
"dest": "cargo/vendor/js-sys-0.3.98"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.97",
|
||||
"contents": "{\"package\": \"67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/js-sys-0.3.98",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3011,27 +3011,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/open/open-5.3.4.crate",
|
||||
"sha256": "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd",
|
||||
"dest": "cargo/vendor/open-5.3.4"
|
||||
"url": "https://static.crates.io/crates/open/open-5.3.5.crate",
|
||||
"sha256": "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c",
|
||||
"dest": "cargo/vendor/open-5.3.5"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/open-5.3.4",
|
||||
"contents": "{\"package\": \"2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/open-5.3.5",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.78.crate",
|
||||
"sha256": "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222",
|
||||
"dest": "cargo/vendor/openssl-0.10.78"
|
||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
|
||||
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
|
||||
"dest": "cargo/vendor/openssl-0.10.80"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-0.10.78",
|
||||
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-0.10.80",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3063,14 +3063,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.114.crate",
|
||||
"sha256": "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.114"
|
||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
|
||||
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.116"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.114",
|
||||
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/openssl-sys-0.9.116",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3190,19 +3190,6 @@
|
||||
"dest": "cargo/vendor/percent-encoding-2.3.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/phf/phf-0.11.3.crate",
|
||||
"sha256": "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078",
|
||||
"dest": "cargo/vendor/phf-0.11.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/phf-0.11.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3229,19 +3216,6 @@
|
||||
"dest": "cargo/vendor/phf_codegen-0.13.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/phf_generator/phf_generator-0.11.3.crate",
|
||||
"sha256": "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d",
|
||||
"dest": "cargo/vendor/phf_generator-0.11.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/phf_generator-0.11.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3255,19 +3229,6 @@
|
||||
"dest": "cargo/vendor/phf_generator-0.13.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/phf_macros/phf_macros-0.11.3.crate",
|
||||
"sha256": "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216",
|
||||
"dest": "cargo/vendor/phf_macros-0.11.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/phf_macros-0.11.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3281,19 +3242,6 @@
|
||||
"dest": "cargo/vendor/phf_macros-0.13.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/phf_shared/phf_shared-0.11.3.crate",
|
||||
"sha256": "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5",
|
||||
"dest": "cargo/vendor/phf_shared-0.11.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/phf_shared-0.11.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3544,14 +3492,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.2.crate",
|
||||
"sha256": "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d",
|
||||
"dest": "cargo/vendor/quick-xml-0.39.2"
|
||||
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.4.crate",
|
||||
"sha256": "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e",
|
||||
"dest": "cargo/vendor/quick-xml-0.39.4"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/quick-xml-0.39.2",
|
||||
"contents": "{\"package\": \"cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/quick-xml-0.39.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -3632,19 +3580,6 @@
|
||||
"dest": "cargo/vendor/r-efi-6.0.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rand/rand-0.8.6.crate",
|
||||
"sha256": "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a",
|
||||
"dest": "cargo/vendor/rand-0.8.6"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rand-0.8.6",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3671,19 +3606,6 @@
|
||||
"dest": "cargo/vendor/rand_chacha-0.9.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate",
|
||||
"sha256": "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c",
|
||||
"dest": "cargo/vendor/rand_core-0.6.4"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rand_core-0.6.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -4272,27 +4194,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/serde_with/serde_with-3.18.0.crate",
|
||||
"sha256": "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f",
|
||||
"dest": "cargo/vendor/serde_with-3.18.0"
|
||||
"url": "https://static.crates.io/crates/serde_with/serde_with-3.20.0.crate",
|
||||
"sha256": "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2",
|
||||
"dest": "cargo/vendor/serde_with-3.20.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_with-3.18.0",
|
||||
"contents": "{\"package\": \"e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_with-3.20.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.18.0.crate",
|
||||
"sha256": "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65",
|
||||
"dest": "cargo/vendor/serde_with_macros-3.18.0"
|
||||
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.20.0.crate",
|
||||
"sha256": "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac",
|
||||
"dest": "cargo/vendor/serde_with_macros-3.20.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_with_macros-3.18.0",
|
||||
"contents": "{\"package\": \"b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/serde_with_macros-3.20.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4428,14 +4350,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/siphasher/siphasher-1.0.2.crate",
|
||||
"sha256": "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e",
|
||||
"dest": "cargo/vendor/siphasher-1.0.2"
|
||||
"url": "https://static.crates.io/crates/siphasher/siphasher-1.0.3.crate",
|
||||
"sha256": "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649",
|
||||
"dest": "cargo/vendor/siphasher-1.0.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/siphasher-1.0.2",
|
||||
"contents": "{\"package\": \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/siphasher-1.0.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4727,14 +4649,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tao/tao-0.35.0.crate",
|
||||
"sha256": "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159",
|
||||
"dest": "cargo/vendor/tao-0.35.0"
|
||||
"url": "https://static.crates.io/crates/tao/tao-0.35.2.crate",
|
||||
"sha256": "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4",
|
||||
"dest": "cargo/vendor/tao-0.35.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tao-0.35.0",
|
||||
"contents": "{\"package\": \"a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tao-0.35.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4766,79 +4688,79 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri/tauri-2.11.0.crate",
|
||||
"sha256": "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66",
|
||||
"dest": "cargo/vendor/tauri-2.11.0"
|
||||
"url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
|
||||
"sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
|
||||
"dest": "cargo/vendor/tauri-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-2.11.0",
|
||||
"contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.0.crate",
|
||||
"sha256": "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.0"
|
||||
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
|
||||
"sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.0",
|
||||
"contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-build-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.0.crate",
|
||||
"sha256": "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.0"
|
||||
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
|
||||
"sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.0",
|
||||
"contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-codegen-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.0.crate",
|
||||
"sha256": "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.0"
|
||||
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
|
||||
"sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.0",
|
||||
"contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-macros-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.0.crate",
|
||||
"sha256": "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.0"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
|
||||
"sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.0",
|
||||
"contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-2.6.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.0.crate",
|
||||
"sha256": "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.1.crate",
|
||||
"sha256": "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0",
|
||||
"contents": "{\"package\": \"65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4862,27 +4784,27 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.0.crate",
|
||||
"sha256": "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.1.crate",
|
||||
"sha256": "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0",
|
||||
"contents": "{\"package\": \"b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-fs-2.5.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.8.crate",
|
||||
"sha256": "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.8"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.9.crate",
|
||||
"sha256": "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.9"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.8",
|
||||
"contents": "{\"package\": \"b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.9",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -4927,53 +4849,53 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.2.crate",
|
||||
"sha256": "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea",
|
||||
"dest": "cargo/vendor/tauri-plugin-store-2.4.2"
|
||||
"url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.3.crate",
|
||||
"sha256": "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b",
|
||||
"dest": "cargo/vendor/tauri-plugin-store-2.4.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-store-2.4.2",
|
||||
"contents": "{\"package\": \"6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-plugin-store-2.4.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.0.crate",
|
||||
"sha256": "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.0"
|
||||
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
|
||||
"sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.0",
|
||||
"contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.0.crate",
|
||||
"sha256": "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0"
|
||||
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
|
||||
"sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0",
|
||||
"contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.0.crate",
|
||||
"sha256": "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.0"
|
||||
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
|
||||
"sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.0",
|
||||
"contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tauri-utils-2.9.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5148,14 +5070,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tokio/tokio-1.52.1.crate",
|
||||
"sha256": "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6",
|
||||
"dest": "cargo/vendor/tokio-1.52.1"
|
||||
"url": "https://static.crates.io/crates/tokio/tokio-1.52.3.crate",
|
||||
"sha256": "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe",
|
||||
"dest": "cargo/vendor/tokio-1.52.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-1.52.1",
|
||||
"contents": "{\"package\": \"8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tokio-1.52.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5369,14 +5291,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.8.crate",
|
||||
"sha256": "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8",
|
||||
"dest": "cargo/vendor/tower-http-0.6.8"
|
||||
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.10.crate",
|
||||
"sha256": "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51",
|
||||
"dest": "cargo/vendor/tower-http-0.6.10"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tower-http-0.6.8",
|
||||
"contents": "{\"package\": \"68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tower-http-0.6.10",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5837,66 +5759,66 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.120.crate",
|
||||
"sha256": "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.120"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.121.crate",
|
||||
"sha256": "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.121"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.120",
|
||||
"contents": "{\"package\": \"49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-0.2.121",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.70.crate",
|
||||
"sha256": "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.71.crate",
|
||||
"sha256": "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70",
|
||||
"contents": "{\"package\": \"96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.120.crate",
|
||||
"sha256": "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.121.crate",
|
||||
"sha256": "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120",
|
||||
"contents": "{\"package\": \"8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.120.crate",
|
||||
"sha256": "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.121.crate",
|
||||
"sha256": "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120",
|
||||
"contents": "{\"package\": \"d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.120.crate",
|
||||
"sha256": "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120"
|
||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.121.crate",
|
||||
"sha256": "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120",
|
||||
"contents": "{\"package\": \"c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -5954,14 +5876,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.97.crate",
|
||||
"sha256": "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602",
|
||||
"dest": "cargo/vendor/web-sys-0.3.97"
|
||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.98.crate",
|
||||
"sha256": "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa",
|
||||
"dest": "cargo/vendor/web-sys-0.3.98"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.97",
|
||||
"contents": "{\"package\": \"4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/web-sys-0.3.98",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7046,14 +6968,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.2.crate",
|
||||
"sha256": "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0",
|
||||
"dest": "cargo/vendor/winnow-1.0.2"
|
||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.3.crate",
|
||||
"sha256": "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1",
|
||||
"dest": "cargo/vendor/winnow-1.0.3"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/winnow-1.0.2",
|
||||
"contents": "{\"package\": \"0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/winnow-1.0.3",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7176,14 +7098,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/wry/wry-0.55.0.crate",
|
||||
"sha256": "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429",
|
||||
"dest": "cargo/vendor/wry-0.55.0"
|
||||
"url": "https://static.crates.io/crates/wry/wry-0.55.1.crate",
|
||||
"sha256": "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514",
|
||||
"dest": "cargo/vendor/wry-0.55.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wry-0.55.0",
|
||||
"contents": "{\"package\": \"186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/wry-0.55.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
@@ -7267,14 +7189,14 @@
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.7.crate",
|
||||
"sha256": "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.7"
|
||||
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate",
|
||||
"sha256": "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.8"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.7",
|
||||
"contents": "{\"package\": \"0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/zerofrom-0.1.8",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
@@ -289,6 +292,9 @@ packages:
|
||||
'@tauri-apps/plugin-os@2.3.2':
|
||||
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':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
|
||||
@@ -763,6 +769,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
Generated
+104
-162
@@ -163,6 +163,15 @@ dependencies = [
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -259,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -478,7 +487,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -1331,9 +1340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1365,9 +1374,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1681,7 +1690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1701,16 +1710,6 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
@@ -1805,9 +1804,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.97"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -2006,7 +2005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2368,9 +2367,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.4"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -2380,15 +2379,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.78"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -2412,9 +2410,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.114"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2514,24 +2512,14 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -2541,18 +2529,8 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.6",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2562,20 +2540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2584,22 +2549,13 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -2780,9 +2736,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.2"
|
||||
version = "0.39.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -2816,7 +2772,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2863,15 +2819,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
@@ -2879,7 +2826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2889,15 +2836,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -3317,7 +3258,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
@@ -3444,11 +3385,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
@@ -3463,9 +3405,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.18.0"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@@ -3571,9 +3513,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
@@ -3659,7 +3601,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
@@ -3669,8 +3611,8 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@@ -3812,9 +3754,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.0"
|
||||
version = "0.35.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
|
||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
@@ -3869,9 +3811,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.0"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66"
|
||||
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -3920,9 +3862,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988"
|
||||
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -3941,9 +3883,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e"
|
||||
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -3968,9 +3910,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc"
|
||||
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -3982,9 +3924,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.0"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57"
|
||||
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -3998,9 +3940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -4033,9 +3975,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -4051,15 +3993,15 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.8"
|
||||
version = "2.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d"
|
||||
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
@@ -4130,9 +4072,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
||||
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
@@ -4146,9 +4088,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.0"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95"
|
||||
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
@@ -4171,9 +4113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.11.0"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117"
|
||||
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
@@ -4197,9 +4139,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.0"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7"
|
||||
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
@@ -4213,7 +4155,7 @@ dependencies = [
|
||||
"json-patch",
|
||||
"log",
|
||||
"memchr",
|
||||
"phf 0.11.3",
|
||||
"phf",
|
||||
"plist",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4365,9 +4307,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4461,7 +4403,7 @@ dependencies = [
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.2",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4524,7 +4466,7 @@ dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.2",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4533,7 +4475,7 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.2",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4559,20 +4501,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4870,9 +4812,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.120"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4883,9 +4825,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.70"
|
||||
version = "0.4.71"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
|
||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -4893,9 +4835,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.120"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -4903,9 +4845,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.120"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -4916,9 +4858,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.120"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -4972,9 +4914,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.97"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
|
||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -4996,7 +4938,7 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
@@ -5736,9 +5678,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -5855,9 +5797,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.55.0"
|
||||
version = "0.55.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429"
|
||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
@@ -5963,9 +5905,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for Moku",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:tray:default",
|
||||
"core:app:allow-default-window-icon",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"shell:allow-open",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-spawn",
|
||||
@@ -27,6 +33,7 @@
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"process:default",
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
@@ -38,4 +45,4 @@
|
||||
"discord-rpc:allow-clear-activity",
|
||||
"discord-rpc:allow-is-running"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@ use crate::ServerState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[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>();
|
||||
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)
|
||||
.ok();
|
||||
|
||||
let binary_args = binary_args.unwrap_or_default();
|
||||
|
||||
server::do_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 =
|
||||
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
|
||||
})?;
|
||||
|
||||
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") {
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
@@ -77,4 +94,4 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
|
||||
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
server::kill_tachidesk(&app);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::server::resolve::strip_unc;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,19 +52,95 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<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]
|
||||
pub fn exit_app(app: tauri::AppHandle) {
|
||||
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]
|
||||
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
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())?;
|
||||
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())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,10 +148,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||
use crate::server::resolve::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);
|
||||
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(())
|
||||
@@ -85,10 +169,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
|
||||
crate::server::kill_tachidesk(&app);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
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);
|
||||
if p.is_dir() {
|
||||
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||
|
||||
+112
-3
@@ -2,13 +2,81 @@ mod commands;
|
||||
mod server;
|
||||
|
||||
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;
|
||||
|
||||
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)]
|
||||
pub fn run() {
|
||||
if signal_existing_instance() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_discord_rpc::init())
|
||||
@@ -34,6 +102,7 @@ pub fn run() {
|
||||
commands::system::reset_suwayomi_data,
|
||||
commands::system::open_path,
|
||||
commands::system::pick_downloads_folder,
|
||||
commands::system::pick_server_binary,
|
||||
commands::backup::export_app_data,
|
||||
commands::backup::import_app_data,
|
||||
commands::backup::auto_backup_app_data,
|
||||
@@ -44,12 +113,52 @@ pub fn run() {
|
||||
commands::biometric::windows_hello_authenticate,
|
||||
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| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
server::kill_tachidesk(window.app_handle());
|
||||
}
|
||||
})
|
||||
.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"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
|
||||
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");
|
||||
|
||||
if !conf_path.exists() {
|
||||
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
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}");
|
||||
}
|
||||
return;
|
||||
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
|
||||
let patched = 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",
|
||||
"false",
|
||||
),
|
||||
@@ -70,4 +79,4 @@ fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||
out.push_str(&replacement);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
}
|
||||
+76
-175
@@ -1,7 +1,6 @@
|
||||
use crate::server::do_log;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -48,22 +47,14 @@ pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[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");
|
||||
fn java_bin_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||
}
|
||||
|
||||
do_log(
|
||||
log,
|
||||
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
|
||||
);
|
||||
if java.exists() {
|
||||
Some(java)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
|
||||
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
fn data_root_args() -> Vec<String> {
|
||||
@@ -74,24 +65,34 @@ fn jar_data_root_flag() -> String {
|
||||
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(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
log: &mut Option<std::fs::File>,
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||
do_log(log, &format!("[resolve] binary={:?}", binary));
|
||||
|
||||
if !binary.trim().is_empty() {
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] user path: {:?} exists={}", path, path.exists()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
|
||||
if path.exists() {
|
||||
let working_dir = path.parent().map(|p| p.to_path_buf());
|
||||
return Ok(ServerInvocation {
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
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");
|
||||
@@ -101,10 +102,7 @@ pub fn resolve_server_binary(
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||
let p = bin_dir.join(name);
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] sibling: {:?} exists={}", p, p.exists()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
@@ -116,54 +114,31 @@ pub fn resolve_server_binary(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
let stripped = strip_unc(raw);
|
||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||
stripped
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
let stripped = strip_unc(raw);
|
||||
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
|
||||
stripped
|
||||
};
|
||||
|
||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||
|
||||
do_log(
|
||||
log,
|
||||
&format!(
|
||||
"[resolve] bundle_dir={:?} exists={}",
|
||||
bundle_dir,
|
||||
bundle_dir.exists()
|
||||
),
|
||||
);
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] jar={:?} exists={}", jar, jar.exists()),
|
||||
);
|
||||
do_log(log, &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) {
|
||||
Some(java) if jar.exists() => {
|
||||
do_log(log, "[resolve] using bundled JRE");
|
||||
return Ok(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(bundle_dir),
|
||||
});
|
||||
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
|
||||
if jar.exists() {
|
||||
do_log(log, "[resolve] using bundled JRE + jar");
|
||||
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
|
||||
for name in &[
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
] {
|
||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(
|
||||
log,
|
||||
&format!("[resolve] sidecar: {:?} exists={}", p, p.exists()),
|
||||
);
|
||||
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
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) {
|
||||
let jar = std::fs::read_dir(&resource_dir).ok().and_then(|mut rd| {
|
||||
rd.find(|e| {
|
||||
e.as_ref()
|
||||
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(
|
||||
log,
|
||||
&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),
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(jar_invocation(java, jar_path, resource_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,108 +166,43 @@ pub fn resolve_server_binary(
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
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(
|
||||
log,
|
||||
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
|
||||
);
|
||||
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
|
||||
const NATIVE_NAMES: &[&str] = &[
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
|
||||
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||
|
||||
let mut found_binary: Option<ServerInvocation> = None;
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
|
||||
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 {
|
||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||
.min_depth(depth as usize)
|
||||
.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() {
|
||||
let java_exe = dir.join("bin").join("java");
|
||||
if java_exe.exists() {
|
||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||
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 java.exists() && jar.exists() {
|
||||
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
|
||||
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||
}
|
||||
|
||||
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());
|
||||
if launcher_sh.exists() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
|
||||
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
|
||||
return Ok(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,
|
||||
bin: launcher_sh.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
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"] {
|
||||
@@ -314,6 +214,7 @@ pub fn resolve_server_binary(
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let resolved = std::process::Command::new("which")
|
||||
.arg(name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+204
-11
@@ -4,7 +4,7 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, setActiveDownloads } from "@store/state.svelte";
|
||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||
@@ -31,6 +31,9 @@
|
||||
let themeEditorOpen = $state(false);
|
||||
let themeEditorEditId = $state<string | null>(null);
|
||||
|
||||
let closeDialogOpen = $state(false);
|
||||
let closeRemember = $state(false);
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorEditId = id ?? null;
|
||||
themeEditorOpen = true;
|
||||
@@ -41,6 +44,35 @@
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
async function doQuit() {
|
||||
if (store.settings.autoStartServer) {
|
||||
await Promise.race([
|
||||
invoke("kill_server").catch(() => {}),
|
||||
new Promise(res => setTimeout(res, 2000)),
|
||||
]);
|
||||
}
|
||||
await invoke("exit_app");
|
||||
}
|
||||
|
||||
async function doHide() {
|
||||
await win.hide();
|
||||
}
|
||||
|
||||
async function handleCloseRequested() {
|
||||
const action = store.settings.closeAction ?? "ask";
|
||||
if (action === "tray") { await doHide(); return; }
|
||||
if (action === "quit") { await doQuit(); return; }
|
||||
closeDialogOpen = true;
|
||||
}
|
||||
|
||||
async function confirmClose(choice: "tray" | "quit") {
|
||||
closeDialogOpen = false;
|
||||
if (closeRemember) updateSettings({ closeAction: choice });
|
||||
closeRemember = false;
|
||||
if (choice === "tray") await doHide();
|
||||
else await doQuit();
|
||||
}
|
||||
|
||||
$effect(() => { void store.settings.theme; applyTheme(); });
|
||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||
$effect(() => mountZoomKey());
|
||||
@@ -59,6 +91,13 @@
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
return () => clearInterval(dlInterval);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
@@ -93,32 +132,33 @@
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
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;
|
||||
else console.warn("Could not start server:", err);
|
||||
});
|
||||
}
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
"download-progress",
|
||||
e => setActiveDownloads(e.payload),
|
||||
);
|
||||
|
||||
await downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
|
||||
return () => {
|
||||
stopProbe();
|
||||
clearInterval(dlInterval);
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
unlistenClose();
|
||||
destroyRpc();
|
||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
@@ -152,7 +192,7 @@
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
@@ -165,7 +205,160 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if closeDialogOpen}
|
||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="close-header">
|
||||
<p class="close-title">Close Moku?</p>
|
||||
<p class="close-sub">Choose how the app should exit.</p>
|
||||
</div>
|
||||
<div class="close-actions">
|
||||
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||
<span class="close-btn-label">Minimize to Tray</span>
|
||||
<span class="close-btn-desc">Keep running in the background</span>
|
||||
</button>
|
||||
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||
<span class="close-btn-label">Quit</span>
|
||||
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||
<span class="close-remember-label">Remember my choice</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
.close-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
width: 300px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||
0 20px 60px rgba(0,0,0,0.65),
|
||||
0 6px 20px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.close-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-sub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 10px var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
|
||||
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
||||
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
||||
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
||||
|
||||
.close-btn-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.close-btn-desc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.close-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-remember-toggle {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.close-remember-thumb {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
transition: transform var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-remember-toggle.on .close-remember-thumb {
|
||||
transform: translateX(12px);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.close-remember-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
</style>
|
||||
+20
-2
@@ -1,5 +1,5 @@
|
||||
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 { getBlobUrl } from "@core/cache/imageCache";
|
||||
|
||||
@@ -104,6 +104,15 @@ export async function gql<T>(
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): 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 res = await fetchWithRetry(
|
||||
`${getServerUrl()}/api/graphql`,
|
||||
@@ -111,12 +120,21 @@ export async function gql<T>(
|
||||
signal,
|
||||
);
|
||||
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();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) {
|
||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||
if (isAuthError && !boot.skipped) {
|
||||
const retried = await tryRefreshAndRetry();
|
||||
if (retried) return retried;
|
||||
|
||||
boot.sessionExpired = true;
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
|
||||
@@ -41,6 +41,48 @@ export const UPDATE_SOURCE_PREFERENCE = `
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOURCE_METAS = `
|
||||
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
||||
setSourceMetas(input: $input) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_SOURCE_METAS = `
|
||||
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
||||
deleteSourceMetas(input: $input) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_SOURCE_METADATA = `
|
||||
mutation UpdateSourceMetadata(
|
||||
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
||||
$hasPreUpdateDeletions: Boolean!
|
||||
$updateInput: SetSourceMetasInput!
|
||||
$hasUpdates: Boolean!
|
||||
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
||||
$hasPostUpdateDeletions: Boolean!
|
||||
$migrateInput: SetSourceMetasInput!
|
||||
$isMigration: Boolean!
|
||||
) {
|
||||
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
||||
metas { sourceId key value }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SET_SOURCE_META = `
|
||||
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||
|
||||
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken(input: {}) { accessToken }
|
||||
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -22,7 +22,78 @@ export const GET_SOURCES = `
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest baseUrl
|
||||
isConfigurable supportsLatest
|
||||
extension { pkgName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SOURCE_SETTINGS = `
|
||||
query GetSourceSettings($id: LongString!) {
|
||||
source(id: $id) {
|
||||
id
|
||||
displayName
|
||||
preferences {
|
||||
... on CheckBoxPreference {
|
||||
type: __typename
|
||||
CheckBoxTitle: title
|
||||
CheckBoxSummary: summary
|
||||
CheckBoxDefault: default
|
||||
CheckBoxCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on SwitchPreference {
|
||||
type: __typename
|
||||
SwitchPreferenceTitle: title
|
||||
SwitchPreferenceSummary: summary
|
||||
SwitchPreferenceDefault: default
|
||||
SwitchPreferenceCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on ListPreference {
|
||||
type: __typename
|
||||
ListPreferenceTitle: title
|
||||
ListPreferenceSummary: summary
|
||||
ListPreferenceDefault: default
|
||||
ListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
... on EditTextPreference {
|
||||
type: __typename
|
||||
EditTextPreferenceTitle: title
|
||||
EditTextPreferenceSummary: summary
|
||||
EditTextPreferenceDefault: default
|
||||
EditTextPreferenceCurrentValue: currentValue
|
||||
dialogTitle
|
||||
dialogMessage
|
||||
key
|
||||
}
|
||||
... on MultiSelectListPreference {
|
||||
type: __typename
|
||||
MultiSelectListPreferenceTitle: title
|
||||
MultiSelectListPreferenceSummary: summary
|
||||
MultiSelectListPreferenceDefault: default
|
||||
MultiSelectListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MIGRATABLE_SOURCES = `
|
||||
query GetMigratableSources {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
sourceId
|
||||
source {
|
||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
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 {
|
||||
return (menuEl: HTMLElement) => {
|
||||
// Position & move to body
|
||||
function position() {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||
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.top = `${r.bottom + 4}px`;
|
||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||
// clamp to viewport left edge
|
||||
const left = parseFloat(menuEl.style.left);
|
||||
if (left < 8) menuEl.style.left = "8px";
|
||||
menuEl.style.top = `${top}px`;
|
||||
menuEl.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
menuEl.style.visibility = "hidden";
|
||||
document.body.appendChild(menuEl);
|
||||
triggerEl.__selectMenuEl = menuEl;
|
||||
position();
|
||||
|
||||
// Reposition on scroll / resize while open
|
||||
requestAnimationFrame(() => {
|
||||
position();
|
||||
menuEl.style.visibility = "";
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", position, true);
|
||||
window.addEventListener("resize", position);
|
||||
|
||||
|
||||
+494
-16
@@ -10,19 +10,282 @@ export class AuthRequiredError extends Error {
|
||||
}
|
||||
|
||||
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 = {
|
||||
getToken: () => _accessToken,
|
||||
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
||||
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
||||
getSession: () => {
|
||||
const base = getServerBase();
|
||||
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 = {
|
||||
clearTokens() { uiAuth.clearToken(); },
|
||||
clearTokens() {
|
||||
_refreshPromise = null;
|
||||
_jwtSettings = null;
|
||||
_jwtSettingsBase = null;
|
||||
_jwtSettingsFetchedAt = 0;
|
||||
uiAuth.clearToken();
|
||||
},
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
||||
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -32,6 +295,61 @@ function getServerBase(): string {
|
||||
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 {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
@@ -69,27 +387,172 @@ export async function fetchAuthenticated(
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
const token = await getUIAccessToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
return fetch(url, {
|
||||
|
||||
let res = await fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
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" });
|
||||
}
|
||||
|
||||
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> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`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 },
|
||||
),
|
||||
@@ -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})`);
|
||||
const json = await res.json();
|
||||
const token: string | undefined = json?.data?.login?.accessToken;
|
||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
uiAuth.setToken(token);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
const payload = json?.data?.login;
|
||||
const accessToken: string | undefined = payload?.accessToken;
|
||||
const refreshToken: string | undefined = payload?.refreshToken;
|
||||
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> {
|
||||
@@ -123,8 +600,9 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
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 {
|
||||
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 pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
||||
Object.assign(headers, bearerHeader(_accessToken));
|
||||
} else if (mode === "UI_LOGIN" && token) {
|
||||
Object.assign(headers, bearerHeader(token));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
|
||||
Vendored
+12
-6
@@ -1,12 +1,13 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { uiAuth } from "@core/auth";
|
||||
import { getUIAccessToken } from "@core/auth";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
let active = 0;
|
||||
let drainScheduled = false;
|
||||
let clearing = false;
|
||||
|
||||
interface QueueEntry {
|
||||
url: string;
|
||||
@@ -17,10 +18,10 @@ interface QueueEntry {
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
const token = await getUIAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
if (mode === "BASIC_AUTH") {
|
||||
@@ -32,9 +33,12 @@ function getAuthHeaders(): Record<string, 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}`);
|
||||
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);
|
||||
return blobUrl;
|
||||
}
|
||||
@@ -121,8 +125,10 @@ export function cancelQueuedFetches(): void {
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
clearing = true;
|
||||
cancelQueuedFetches();
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
clearing = false;
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * from './memoryCache';
|
||||
export * from './pageCache';
|
||||
export * from './imageCache';
|
||||
export * from './queryCache';
|
||||
export * from './queryCache';
|
||||
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 {
|
||||
if (useBlob) {
|
||||
preloadBlobUrls([url], 0);
|
||||
return;
|
||||
}
|
||||
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+74
-8
@@ -1,11 +1,14 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
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]) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
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);
|
||||
return Promise.reject(err);
|
||||
}) 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);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
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);
|
||||
notify(key);
|
||||
},
|
||||
@@ -43,10 +62,38 @@ export const cache = {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing) return;
|
||||
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(() => {});
|
||||
},
|
||||
|
||||
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); },
|
||||
|
||||
ageOf(key: string): number | undefined {
|
||||
@@ -54,18 +101,35 @@ export const cache = {
|
||||
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) {
|
||||
const keys = groups.get(tag);
|
||||
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);
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
const allKeys = [...store.keys()];
|
||||
store.clear(); groups.clear();
|
||||
store.clear();
|
||||
groups.clear();
|
||||
keyToGroups.clear();
|
||||
allKeys.forEach(notify);
|
||||
},
|
||||
|
||||
@@ -161,7 +225,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||
}
|
||||
|
||||
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.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Keybinds {
|
||||
openSettings: string;
|
||||
toggleBookmark: string;
|
||||
toggleMarker: string;
|
||||
toggleAutoScroll: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
openSettings: "o",
|
||||
toggleBookmark: "m",
|
||||
toggleMarker: "n",
|
||||
toggleAutoScroll: "s",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
@@ -44,4 +46,5 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
openSettings: "Open settings",
|
||||
toggleBookmark: "Toggle bookmark",
|
||||
toggleMarker: "Toggle marker",
|
||||
};
|
||||
toggleAutoScroll: "Toggle auto scroll",
|
||||
};
|
||||
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
|
||||
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 {
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||
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 {
|
||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||
isRunning, getErrored, calcSpeed, estimateEta,
|
||||
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||
type SpeedSample,
|
||||
} from "../lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
@@ -23,8 +24,11 @@ class DownloadStore {
|
||||
dequeueing = $state(new Set<number>());
|
||||
selected = $state(new Set<number>());
|
||||
batchWorking = $state(false);
|
||||
pagesPerSec: number | null = $state(null);
|
||||
eta: number | null = $state(null);
|
||||
pagesPerSec: 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 autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||
@@ -82,6 +86,52 @@ class DownloadStore {
|
||||
this.status = ds;
|
||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||
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) {
|
||||
@@ -172,11 +222,21 @@ class DownloadStore {
|
||||
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) {
|
||||
if (this.dequeueing.has(chapterId)) return;
|
||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||
try {
|
||||
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 });
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
@@ -189,6 +249,8 @@ class DownloadStore {
|
||||
const ids = [...this.erroredIds];
|
||||
try {
|
||||
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 });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
@@ -204,6 +266,8 @@ class DownloadStore {
|
||||
try {
|
||||
if (ids.length > 0) {
|
||||
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 });
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte";
|
||||
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Extension } from "@types/index";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
expanded: boolean;
|
||||
working: Set<string>;
|
||||
anims: boolean;
|
||||
onToggle: (base: string) => void;
|
||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
expanded: boolean;
|
||||
working: Set<string>;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
libraryCount: number;
|
||||
onToggle: (base: string) => void;
|
||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
|
||||
}
|
||||
|
||||
let { base, primary, variants, expanded, working, anims, onToggle, onMutate }: Props = $props();
|
||||
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
|
||||
|
||||
const clickable = $derived(primary.isInstalled);
|
||||
|
||||
const hasVariants = $derived(variants.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="group">
|
||||
<div class="row">
|
||||
<svelte:element
|
||||
this={clickable ? "button" : "div"}
|
||||
class="row"
|
||||
class:row-clickable={clickable}
|
||||
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
|
||||
>
|
||||
<Thumbnail
|
||||
src={primary.iconUrl}
|
||||
alt={primary.name}
|
||||
@@ -31,6 +43,13 @@
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta">
|
||||
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
||||
{#if primary.isInstalled}
|
||||
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
|
||||
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
|
||||
{libraryCount > 0 ? libraryCount : 0}
|
||||
|
||||
</span>
|
||||
{/if}
|
||||
v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -39,22 +58,24 @@
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if primary.hasUpdate}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "update")}>Update</button>
|
||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else if primary.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
||||
<div class="row-actions">
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||
{/if}
|
||||
|
||||
{#if hasVariants}
|
||||
<button class="expand-btn" onclick={() => onToggle(base)} title="{variants.length + 1} languages">
|
||||
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
|
||||
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
<span class="expand-count">{variants.length + 1}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
{#if expanded && hasVariants}
|
||||
<div class="variants" class:variants-anim={anims}>
|
||||
@@ -68,11 +89,11 @@
|
||||
{#if working.has(v.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if v.hasUpdate}
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
|
||||
{:else if v.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => onMutate(v.pkgName, "uninstall")}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
|
||||
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,15 +104,18 @@
|
||||
|
||||
<style>
|
||||
.group { display: flex; flex-direction: column; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row-clickable { cursor: pointer; }
|
||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
.action-btn:hover { filter: brightness(1.1); }
|
||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
@@ -106,5 +130,5 @@
|
||||
.variant-row:hover { background: var(--bg-raised); }
|
||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.variant-actions { flex-shrink: 0; }
|
||||
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { gql } from "@api/client";
|
||||
import { setPreviewManga } from "@store/state.svelte";
|
||||
import { GET_LIBRARY, GET_SOURCES } from "@api/queries";
|
||||
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "../lib/extensionLibrary";
|
||||
import SourceMigrateModal from "../panels/SourceMigrateModal.svelte";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
pkgName: string;
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
cols: number;
|
||||
cropCovers: boolean;
|
||||
statsAlways: boolean;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
onBack: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||
|
||||
let groups: SourceLibrary[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
|
||||
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);
|
||||
|
||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||
|
||||
const filtered = $derived((() => {
|
||||
let items = 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([]);
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [libData, srcData] = await Promise.all([
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES),
|
||||
]);
|
||||
sourceNodes = srcData.sources.nodes;
|
||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter(f: ContentFilter) {
|
||||
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters = {};
|
||||
}
|
||||
|
||||
function openMigrate(group: SourceLibrary) {
|
||||
const node = sourceNodes.find(s => s.id === group.sourceId);
|
||||
migrateTarget = {
|
||||
sourceId: group.sourceId,
|
||||
sourceName: group.displayName,
|
||||
iconUrl: (node as any)?.iconUrl ?? iconUrl,
|
||||
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>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="header-btn" onclick={onBack}>
|
||||
<ArrowLeft size={14} weight="bold" />
|
||||
</button>
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="title-block">
|
||||
<span class="eyebrow">In Library</span>
|
||||
<span class="title">{extensionName}</span>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
||||
{/if}
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
</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}
|
||||
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each Array(12) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
||||
</div>
|
||||
{:else}
|
||||
{#if groups.length > 1}
|
||||
<div class="source-groups">
|
||||
{#each groups as group}
|
||||
<div class="source-group-header">
|
||||
<span class="source-group-name">{group.displayName}</span>
|
||||
<span class="source-group-count">{group.manga.length}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(group)} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if groups.length === 1}
|
||||
<div class="single-source-bar">
|
||||
<span class="source-group-name">{groups[0].displayName}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each filtered as m (m.id)}
|
||||
{@const isCompleted = !m.unreadCount && m.downloadCount > 0}
|
||||
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<Thumbnail
|
||||
src={resolvedCover(m.id, m.thumbnailUrl)}
|
||||
alt={m.title}
|
||||
class="cover"
|
||||
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if migrateTarget}
|
||||
<SourceMigrateModal
|
||||
sourceId={migrateTarget.sourceId}
|
||||
sourceName={migrateTarget.sourceName}
|
||||
sourceIconUrl={migrateTarget.iconUrl}
|
||||
manga={migrateTarget.manga}
|
||||
onClose={() => migrateTarget = null}
|
||||
onDone={() => { migrateTarget = null; load(); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.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:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
|
||||
.title-block { display: flex; flex-direction: column; gap: 1px; }
|
||||
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.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); }
|
||||
|
||||
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.source-group-header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.single-source-bar { display: flex; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-dim); }
|
||||
.source-group-name { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); font-weight: var(--weight-medium); }
|
||||
.source-group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); }
|
||||
.migrate-btn { display: flex; align-items: center; gap: 5px; margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 9px; border-radius: var(--radius-sm); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.migrate-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
|
||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||
.card-info-overlay.instant { transition: none; }
|
||||
.card-info-overlay.always { opacity: 1; }
|
||||
.card:hover .card-info-overlay { opacity: 1; }
|
||||
|
||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card.anims .card-title { transition: color var(--t-base); }
|
||||
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -3,14 +3,21 @@
|
||||
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries";
|
||||
import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
|
||||
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
||||
import type { Extension } from "@types/index";
|
||||
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||
import ExtensionCard from "./ExtensionCard.svelte";
|
||||
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
|
||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||
import ExtensionCard from "./ExtensionCard.svelte";
|
||||
import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte";
|
||||
import ExtensionLibrary from "./ExtensionLibrary.svelte";
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
const cols = $derived(store.settings.libraryCols ?? 5);
|
||||
const cropCovers = $derived(store.settings.cropCovers ?? true);
|
||||
const statsAlways = $derived(store.settings.statsAlways ?? false);
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
let tabIndicator = $state({ left: 0, width: 0 });
|
||||
|
||||
@@ -33,6 +40,15 @@
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel = $state<Panel>(null);
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
|
||||
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
|
||||
|
||||
let settingsTarget = $state<SettingsTarget | null>(null);
|
||||
let libraryTarget = $state<LibraryTarget | null>(null);
|
||||
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
|
||||
let libCountByPkg = $state<Record<string, number>>({});
|
||||
|
||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
let externalUrl = $state("");
|
||||
@@ -47,8 +63,25 @@
|
||||
let savingRepos = $state(false);
|
||||
|
||||
async function load() {
|
||||
const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error);
|
||||
if (d) extensions = d.extensions.nodes;
|
||||
const [extData, srcData, libData] = await Promise.all([
|
||||
gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
|
||||
]);
|
||||
if (extData) extensions = extData.extensions.nodes;
|
||||
if (srcData) {
|
||||
const map: Record<string, SourceEntry[]> = {};
|
||||
for (const s of srcData.sources.nodes) {
|
||||
if (!s.isConfigurable || !s.extension?.pkgName) continue;
|
||||
const pkg = s.extension.pkgName;
|
||||
if (!map[pkg]) map[pkg] = [];
|
||||
map[pkg].push({ id: s.id, displayName: s.displayName });
|
||||
}
|
||||
sourcesByPkg = map;
|
||||
}
|
||||
if (libData && srcData) {
|
||||
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalManga() {
|
||||
@@ -213,111 +246,135 @@
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<div class="root anim-fade-in">
|
||||
<ExtensionFilters
|
||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||
{anims} {tabIndicator} {updatingAll}
|
||||
bind:tabsEl
|
||||
onFilter={setFilter}
|
||||
onSearch={(q) => search = q}
|
||||
onLang={(l) => langFilter = l}
|
||||
onPanel={openPanel}
|
||||
onRefresh={fetchFromRepo}
|
||||
onUpdateAll={updateAll}
|
||||
{#if libraryTarget}
|
||||
<ExtensionLibrary
|
||||
pkgName={libraryTarget.pkgName}
|
||||
extensionName={libraryTarget.extensionName}
|
||||
iconUrl={libraryTarget.iconUrl}
|
||||
{cols} {cropCovers} {statsAlways} {anims}
|
||||
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
|
||||
onBack={() => libraryTarget = null}
|
||||
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="root anim-fade-in">
|
||||
<ExtensionFilters
|
||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||
{anims} {tabIndicator} {updatingAll}
|
||||
bind:tabsEl
|
||||
onFilter={setFilter}
|
||||
onSearch={(q) => search = q}
|
||||
onLang={(l) => langFilter = l}
|
||||
onPanel={openPanel}
|
||||
onRefresh={fetchFromRepo}
|
||||
onUpdateAll={updateAll}
|
||||
/>
|
||||
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
|
||||
</div>
|
||||
<div class="ext-row">
|
||||
<input
|
||||
class="ext-input" class:error={installError}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
oninput={() => installError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
use:focusOnMount
|
||||
/>
|
||||
<button class="install-btn" class:success={installSuccess}
|
||||
onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
{:else}Install{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panel === "repos"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
|
||||
</div>
|
||||
{#if reposLoading}
|
||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
{#if repos.length === 0}
|
||||
<div class="repo-empty">No repos configured.</div>
|
||||
{:else}
|
||||
<div class="repo-list">
|
||||
{#each repos as url}
|
||||
<div class="repo-row">
|
||||
<span class="repo-url">{url}</span>
|
||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
|
||||
</div>
|
||||
<div class="ext-row">
|
||||
<input
|
||||
class="ext-input" class:error={repoError}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
bind:value={newRepoUrl} disabled={savingRepos}
|
||||
oninput={() => repoError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
class="ext-input" class:error={installError}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
oninput={() => installError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
use:focusOnMount
|
||||
/>
|
||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||
<button class="install-btn" class:success={installSuccess}
|
||||
onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
{:else}Install{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#if showLocal}
|
||||
<div class="local-row">
|
||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||
<div class="info">
|
||||
<span class="name">Local Source</span>
|
||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
||||
</div>
|
||||
<span class="local-badge">Built-in</span>
|
||||
{#if panel === "repos"}
|
||||
<div class="ext-panel" class:ext-panel-anim={anims}>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each groups as { base, primary, variants }}
|
||||
<ExtensionCard
|
||||
{base} {primary} {variants} {working} {anims}
|
||||
expanded={expanded.has(base)}
|
||||
onToggle={toggleExpand}
|
||||
onMutate={mutate}
|
||||
/>
|
||||
{/each}
|
||||
{#if !showLocal && groups.length === 0}
|
||||
<div class="empty" style="flex:1">No extensions found.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if reposLoading}
|
||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
{#if repos.length === 0}
|
||||
<div class="repo-empty">No repos configured.</div>
|
||||
{:else}
|
||||
<div class="repo-list">
|
||||
{#each repos as url}
|
||||
<div class="repo-row">
|
||||
<span class="repo-url">{url}</span>
|
||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ext-row">
|
||||
<input
|
||||
class="ext-input" class:error={repoError}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
bind:value={newRepoUrl} disabled={savingRepos}
|
||||
oninput={() => repoError = null}
|
||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
/>
|
||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#if showLocal}
|
||||
<div class="local-row">
|
||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||
<div class="info">
|
||||
<span class="name">Local Source</span>
|
||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
||||
</div>
|
||||
<span class="local-badge">Built-in</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each groups as { base, primary, variants }}
|
||||
<ExtensionCard
|
||||
{base} {primary} {variants} {working} {anims}
|
||||
sources={sourcesByPkg[primary.pkgName] ?? []}
|
||||
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
|
||||
expanded={expanded.has(base)}
|
||||
onToggle={toggleExpand}
|
||||
onMutate={mutate}
|
||||
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
|
||||
/>
|
||||
{/each}
|
||||
{#if !showLocal && groups.length === 0}
|
||||
<div class="empty" style="flex:1">No extensions found.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settingsTarget}
|
||||
<ExtensionSettingsPanel
|
||||
extensionName={settingsTarget.extensionName}
|
||||
iconUrl={settingsTarget.iconUrl}
|
||||
sources={settingsTarget.sources}
|
||||
onClose={() => settingsTarget = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface LibraryManga {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
unreadCount: number;
|
||||
downloadCount: number;
|
||||
source: { id: string; displayName: string };
|
||||
}
|
||||
|
||||
export interface SourceLibrary {
|
||||
sourceId: string;
|
||||
displayName: string;
|
||||
manga: LibraryManga[];
|
||||
}
|
||||
|
||||
export type SourceNode = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
isConfigurable: boolean;
|
||||
extension: { pkgName: string };
|
||||
};
|
||||
|
||||
export function libraryByExtension(
|
||||
libraryManga: LibraryManga[],
|
||||
sources: SourceNode[],
|
||||
pkgName: string,
|
||||
): SourceLibrary[] {
|
||||
const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName);
|
||||
const sourceIds = new Set(pkgSources.map(s => s.id));
|
||||
|
||||
const bySource = new Map<string, LibraryManga[]>();
|
||||
for (const src of pkgSources) bySource.set(src.id, []);
|
||||
for (const m of libraryManga) {
|
||||
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
||||
}
|
||||
|
||||
return pkgSources
|
||||
.map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! }))
|
||||
.filter(g => g.manga.length > 0);
|
||||
}
|
||||
|
||||
export function libraryCountByPkg(
|
||||
libraryManga: LibraryManga[],
|
||||
sources: SourceNode[],
|
||||
): Record<string, number> {
|
||||
const sourceIdToPkg = new Map<string, string>();
|
||||
for (const s of sources) {
|
||||
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
|
||||
}
|
||||
const counts: Record<string, number> = {};
|
||||
for (const m of libraryManga) {
|
||||
const pkg = sourceIdToPkg.get(m.source.id);
|
||||
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import { X, CircleNotch, CaretUpDown, Check, CaretLeft } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { addToast } from "@store/state.svelte";
|
||||
import { GET_SOURCE_SETTINGS } from "@api/queries";
|
||||
import { UPDATE_SOURCE_PREFERENCE } from "@api/mutations";
|
||||
|
||||
interface Preference {
|
||||
type: string;
|
||||
key: string;
|
||||
CheckBoxTitle?: string;
|
||||
CheckBoxSummary?: string;
|
||||
CheckBoxDefault?: boolean;
|
||||
CheckBoxCurrentValue?: boolean;
|
||||
SwitchPreferenceTitle?: string;
|
||||
SwitchPreferenceSummary?: string;
|
||||
SwitchPreferenceDefault?: boolean;
|
||||
SwitchPreferenceCurrentValue?: boolean;
|
||||
ListPreferenceTitle?: string;
|
||||
ListPreferenceSummary?: string;
|
||||
ListPreferenceDefault?: string;
|
||||
ListPreferenceCurrentValue?: string;
|
||||
entries?: string[];
|
||||
entryValues?: string[];
|
||||
EditTextPreferenceTitle?: string;
|
||||
EditTextPreferenceSummary?: string;
|
||||
EditTextPreferenceDefault?: string;
|
||||
EditTextPreferenceCurrentValue?: string;
|
||||
dialogTitle?: string;
|
||||
dialogMessage?: string;
|
||||
MultiSelectListPreferenceTitle?: string;
|
||||
MultiSelectListPreferenceSummary?: string;
|
||||
MultiSelectListPreferenceDefault?: string[];
|
||||
MultiSelectListPreferenceCurrentValue?: string[];
|
||||
}
|
||||
|
||||
export type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
sources: SourceEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { extensionName, iconUrl, sources, onClose }: Props = $props();
|
||||
|
||||
let phase = $state<"pick" | "settings">("pick");
|
||||
let activeSource = $state<SourceEntry | null>(null);
|
||||
let prefs = $state<Preference[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state<string | null>(null);
|
||||
let editKey = $state<string | null>(null);
|
||||
let editValue = $state("");
|
||||
let listOpen = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (sources.length === 1) openSource(sources[0]);
|
||||
});
|
||||
|
||||
async function openSource(src: SourceEntry) {
|
||||
activeSource = src;
|
||||
phase = "settings";
|
||||
loading = true;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
try {
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(src.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToPicker() {
|
||||
phase = "pick";
|
||||
activeSource = null;
|
||||
prefs = [];
|
||||
editKey = null;
|
||||
listOpen = null;
|
||||
}
|
||||
|
||||
async function save(position: number, changeType: string, value: unknown) {
|
||||
if (!activeSource) return;
|
||||
const pref = prefs[position];
|
||||
saving = pref.key;
|
||||
try {
|
||||
await gql(UPDATE_SOURCE_PREFERENCE, {
|
||||
source: String(activeSource.id),
|
||||
change: { position, [changeType]: value },
|
||||
});
|
||||
const d = await gql<{ source: { preferences: Preference[] } }>(
|
||||
GET_SOURCE_SETTINGS,
|
||||
{ id: String(activeSource.id) },
|
||||
);
|
||||
prefs = d.source.preferences ?? [];
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||
} finally {
|
||||
saving = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle(p: Preference) {
|
||||
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
|
||||
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
|
||||
}
|
||||
function getSummary(p: Preference) {
|
||||
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
|
||||
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
|
||||
}
|
||||
function getBoolValue(p: Preference) {
|
||||
if (p.type === "CheckBoxPreference")
|
||||
return p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false;
|
||||
return p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false;
|
||||
}
|
||||
function getListValue(p: Preference) {
|
||||
return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? "";
|
||||
}
|
||||
function getListLabel(p: Preference, val: string) {
|
||||
const idx = p.entryValues?.indexOf(val) ?? -1;
|
||||
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
|
||||
}
|
||||
function getMultiValue(p: Preference): string[] {
|
||||
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
|
||||
}
|
||||
function toggleMulti(position: number, p: Preference, val: string) {
|
||||
const current = getMultiValue(p);
|
||||
const next = current.includes(val) ? current.filter(v => v !== val) : [...current, val];
|
||||
save(position, "multiSelectState", next);
|
||||
}
|
||||
function submitEdit(position: number) {
|
||||
save(position, "editTextState", editValue);
|
||||
editKey = null;
|
||||
}
|
||||
function openEdit(p: Preference) {
|
||||
editKey = p.key;
|
||||
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
|
||||
}
|
||||
|
||||
function langTag(displayName: string) {
|
||||
const m = displayName.match(/\(([^)]+)\)$/);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (editKey) { editKey = null; return; }
|
||||
if (listOpen) { listOpen = null; return; }
|
||||
if (phase === "settings" && sources.length > 1) { backToPicker(); return; }
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="backdrop" role="dialog" aria-modal="true" onmousedown={onBackdrop}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-wrap">
|
||||
{#if phase === "settings" && sources.length > 1}
|
||||
<button class="icon-btn" onclick={backToPicker} title="Back">
|
||||
<CaretLeft size={13} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="modal-ext-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="modal-titles">
|
||||
<span class="modal-eyebrow">Extension Settings</span>
|
||||
<span class="modal-title">
|
||||
{phase === "pick" ? extensionName : (activeSource?.displayName ?? extensionName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn" onclick={onClose}>
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{#if phase === "pick"}
|
||||
<div class="source-list">
|
||||
{#each sources as src}
|
||||
{@const tag = langTag(src.displayName)}
|
||||
{@const baseName = src.displayName.replace(/\s*\([^)]+\)$/, "")}
|
||||
<button class="source-row" onclick={() => openSource(src)}>
|
||||
<span class="source-name">{baseName}</span>
|
||||
{#if tag}<span class="lang-badge">{tag}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="center-state">
|
||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
{:else if prefs.length === 0}
|
||||
<div class="center-state empty-state">No configurable settings.</div>
|
||||
{:else}
|
||||
<div class="pref-list">
|
||||
{#each prefs as pref, i}
|
||||
{@const title = getTitle(pref)}
|
||||
{@const summary = getSummary(pref)}
|
||||
{@const isSaving = saving === pref.key}
|
||||
|
||||
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
|
||||
{@const checked = getBoolValue(pref)}
|
||||
<div class="pref-row">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:toggle-on={checked}
|
||||
disabled={isSaving}
|
||||
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
|
||||
>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={10} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<span class="toggle-thumb"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "ListPreference"}
|
||||
{@const current = getListValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<button
|
||||
class="select-btn" class:select-open={listOpen === pref.key}
|
||||
disabled={isSaving}
|
||||
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
|
||||
>
|
||||
<span class="select-val">{getListLabel(pref, current)}</span>
|
||||
{#if isSaving}
|
||||
<CircleNotch size={11} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<CaretUpDown size={11} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if listOpen === pref.key}
|
||||
<div class="dropdown">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
<button
|
||||
class="dropdown-item" class:dropdown-item-active={val === current}
|
||||
onclick={() => { save(i, "listState", val); listOpen = null; }}
|
||||
>
|
||||
{entry}
|
||||
{#if val === current}<Check size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if pref.type === "EditTextPreference"}
|
||||
{#if editKey === pref.key}
|
||||
<div class="pref-row pref-row-col edit-active">
|
||||
<div class="pref-text">
|
||||
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
|
||||
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<input
|
||||
class="edit-input"
|
||||
bind:value={editValue}
|
||||
disabled={isSaving}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }}
|
||||
autofocus
|
||||
/>
|
||||
<button class="action-btn-dim" onclick={() => editKey = null}>Cancel</button>
|
||||
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
|
||||
{#if isSaving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}Save{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<span class="pref-value-hint">
|
||||
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if pref.type === "MultiSelectListPreference"}
|
||||
{@const selected = getMultiValue(pref)}
|
||||
<div class="pref-row pref-row-col">
|
||||
<div class="pref-text">
|
||||
<span class="pref-title">{title}</span>
|
||||
{#if summary}<span class="pref-summary">{summary}</span>{/if}
|
||||
</div>
|
||||
<div class="multi-list">
|
||||
{#each (pref.entries ?? []) as entry, j}
|
||||
{@const val = pref.entryValues?.[j] ?? entry}
|
||||
{@const on = selected.includes(val)}
|
||||
<button
|
||||
class="multi-item" class:multi-item-on={on}
|
||||
disabled={isSaving}
|
||||
onclick={() => toggleMulti(i, pref, val)}
|
||||
>
|
||||
<span class="multi-check">{#if on}<Check size={10} weight="bold" />{/if}</span>
|
||||
{entry}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn 0.15s ease both;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
display: flex; flex-direction: column;
|
||||
width: 400px; max-width: calc(100vw - 32px); max-height: 78vh;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-title-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
:global(.modal-ext-icon) { width: 22px; height: 22px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.modal-titles { display: flex; flex-direction: column; gap: 1px; }
|
||||
.modal-eyebrow {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.modal-body { overflow-y: auto; flex: 1; }
|
||||
|
||||
.source-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.source-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px var(--sp-4);
|
||||
text-align: left;
|
||||
transition: background var(--t-fast);
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.source-row:hover { background: var(--bg-raised); }
|
||||
.source-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.lang-badge {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.center-state { display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.empty-state { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.pref-list { display: flex; flex-direction: column; padding: var(--sp-2) 0; }
|
||||
.pref-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 10px var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.pref-row:last-child { border-bottom: none; }
|
||||
.pref-row-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
||||
.pref-row-btn { width: 100%; text-align: left; transition: background var(--t-fast); }
|
||||
.pref-row-btn:hover { background: var(--bg-raised); }
|
||||
.edit-active { background: var(--bg-raised); }
|
||||
|
||||
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.pref-summary {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
|
||||
}
|
||||
.pref-value-hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative; width: 32px; height: 18px; border-radius: 9px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.toggle-thumb {
|
||||
position: absolute; left: 2px; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--text-faint);
|
||||
transition: left var(--t-base), background var(--t-base); pointer-events: none;
|
||||
}
|
||||
.toggle-on .toggle-thumb { left: 16px; background: var(--accent-fg); }
|
||||
.toggle:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.select-wrap { position: relative; }
|
||||
.select-btn {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
width: 100%; padding: 6px var(--sp-3);
|
||||
background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||
.select-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.select-open { border-color: var(--border-focus); }
|
||||
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); overflow: hidden;
|
||||
box-shadow: var(--shadow-lg); z-index: 10;
|
||||
animation: dropIn 0.12s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.dropdown-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 7px var(--sp-3);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg-raised); }
|
||||
.dropdown-item-active { color: var(--accent-fg); }
|
||||
|
||||
.edit-row { display: flex; gap: var(--sp-2); }
|
||||
.edit-input {
|
||||
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
||||
color: var(--text-primary); font-size: var(--text-sm);
|
||||
outline: none; transition: border-color var(--t-base);
|
||||
}
|
||||
.edit-input:focus { border-color: var(--border-focus); }
|
||||
|
||||
.action-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.action-btn-dim {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.action-btn-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.multi-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.multi-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-2); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
|
||||
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.multi-check {
|
||||
width: 14px; height: 14px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-base);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: var(--accent-fg);
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script lang="ts">
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle, Swap } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { UPDATE_MANGA } from "@api/mutations/manga";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import type { Manga, Chapter, Source } from "@types";
|
||||
import type { LibraryManga } from "../lib/extensionLibrary";
|
||||
|
||||
interface Props {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
sourceIconUrl: string;
|
||||
manga: LibraryManga[];
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
let { sourceId, sourceName, sourceIconUrl, manga, onClose, onDone }: Props = $props();
|
||||
|
||||
type Phase = "pick-target" | "review" | "migrating" | "done";
|
||||
|
||||
interface EntryResult {
|
||||
manga: LibraryManga;
|
||||
match: Manga | null;
|
||||
chapters: Chapter[];
|
||||
similarity: number;
|
||||
status: "pending" | "searching" | "found" | "no-match" | "migrated" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
|
||||
return intersection / new Set([...wordsA, ...wordsB]).size;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && phase !== "migrating") onClose(); }
|
||||
|
||||
let phase: Phase = $state("pick-target");
|
||||
let allSources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let targetSource: Source | null = $state(null);
|
||||
let selectedLang = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
let entries: EntryResult[] = $state([]);
|
||||
let searchProgress = $state({ done: 0, total: 0 });
|
||||
let migrateProgress = $state({ done: 0, total: 0, failed: 0 });
|
||||
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(allSources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return allSources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of allSources) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing || s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
const foundCount = $derived(entries.filter(e => e.status === "found").length);
|
||||
const noMatchCount = $derived(entries.filter(e => e.status === "no-match").length);
|
||||
const migratedCount = $derived(entries.filter(e => e.status === "migrated").length);
|
||||
const failedCount = $derived(entries.filter(e => e.status === "failed").length);
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then(d => {
|
||||
allSources = d.sources.nodes.filter(s => s.id !== "0" && s.id !== sourceId);
|
||||
const prefLang = store?.settings?.preferredExtensionLang ?? "";
|
||||
const langs = new Set(allSources.map(s => s.lang));
|
||||
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const chips = Array.from(langStripEl.children) as HTMLElement[];
|
||||
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
|
||||
if (dir === 1) {
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
|
||||
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
async function startSearch(target: Source) {
|
||||
targetSource = target;
|
||||
phase = "review";
|
||||
entries = manga.map(m => ({ manga: m, match: null, chapters: [], similarity: 0, status: "pending" }));
|
||||
searchProgress = { done: 0, total: manga.length };
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
entries[i] = { ...entries[i], status: "searching" };
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
|
||||
});
|
||||
const results = d.fetchSourceManga.mangas
|
||||
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
||||
.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
if (results.length > 0 && results[0].similarity > 0.3) {
|
||||
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
|
||||
} else {
|
||||
entries[i] = { ...entries[i], status: "no-match" };
|
||||
}
|
||||
} catch (e: any) {
|
||||
entries[i] = { ...entries[i], status: "no-match", error: e.message };
|
||||
}
|
||||
searchProgress = { done: i + 1, total: manga.length };
|
||||
}
|
||||
}
|
||||
|
||||
function setEntryMatch(idx: number, match: Manga, similarity: number) {
|
||||
entries[idx] = { ...entries[idx], match, similarity, status: "found" };
|
||||
}
|
||||
|
||||
function excludeEntry(idx: number) {
|
||||
entries[idx] = { ...entries[idx], status: "no-match", match: null };
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
const toMigrate = entries.filter(e => e.status === "found" && e.match);
|
||||
migrateProgress = { done: 0, total: toMigrate.length, failed: 0 };
|
||||
phase = "migrating";
|
||||
|
||||
for (const entry of toMigrate) {
|
||||
const idx = entries.indexOf(entry);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
|
||||
const newChaps = d.fetchChapters.chapters;
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
|
||||
for (const nc of newChaps) {
|
||||
const oldIdx = entries[idx].manga;
|
||||
if (oldIdx) {
|
||||
toMarkRead.push(nc.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: entry.match!.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: entry.manga.id, inLibrary: false });
|
||||
|
||||
entries[idx] = { ...entries[idx], status: "migrated" };
|
||||
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1 };
|
||||
} catch (e: any) {
|
||||
entries[idx] = { ...entries[idx], status: "failed", error: e.message };
|
||||
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1, failed: migrateProgress.failed + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
phase = "done";
|
||||
addToast({
|
||||
kind: "success",
|
||||
title: "Migration complete",
|
||||
body: `${migrateProgress.done - migrateProgress.failed} migrated, ${migrateProgress.failed} failed`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="source-context">
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={sourceIconUrl} alt={sourceName} class="src-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<div class="source-context-info">
|
||||
<span class="modal-eyebrow">Source migration</span>
|
||||
<span class="modal-title">{sourceName}</span>
|
||||
<span class="modal-sub">{manga.length} {manga.length === 1 ? "title" : "titles"} in library</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if phase !== "migrating"}
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
{#if phase === "pick-target"}
|
||||
<div class="phase-label-row">
|
||||
<span class="phase-label">Select destination source</span>
|
||||
</div>
|
||||
{#if loadingSources}
|
||||
<div class="centered"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if allSources.length === 0}
|
||||
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||
{:else}
|
||||
{#if hasMultipleLangs}
|
||||
<div class="src-lang-bar">
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button class="source-row" onclick={() => startSearch(src)}>
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<div class="source-info">
|
||||
<span class="source-name">{src.displayName}</span>
|
||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if phase === "review" || phase === "migrating" || phase === "done"}
|
||||
<div class="review-header">
|
||||
<div class="review-route">
|
||||
<div class="review-source">
|
||||
<div class="source-icon-wrap small">
|
||||
<Thumbnail src={sourceIconUrl} alt={sourceName} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="review-source-name">{sourceName}</span>
|
||||
</div>
|
||||
<ArrowRight size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
{#if targetSource}
|
||||
<div class="review-source">
|
||||
<div class="source-icon-wrap small">
|
||||
<Thumbnail src={targetSource.iconUrl} alt={targetSource.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="review-source-name">{targetSource.displayName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if phase === "review"}
|
||||
<div class="review-progress-row">
|
||||
<div class="review-progress-bar">
|
||||
<div class="review-progress-fill" style="width:{searchProgress.total ? (searchProgress.done / searchProgress.total) * 100 : 0}%"></div>
|
||||
</div>
|
||||
<span class="review-progress-label">
|
||||
{#if searchProgress.done < searchProgress.total}
|
||||
Searching {searchProgress.done + 1} / {searchProgress.total}…
|
||||
{:else}
|
||||
{foundCount} found · {noMatchCount} no match
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if phase === "migrating"}
|
||||
<div class="review-progress-row">
|
||||
<div class="review-progress-bar">
|
||||
<div class="review-progress-fill" style="width:{migrateProgress.total ? (migrateProgress.done / migrateProgress.total) * 100 : 0}%"></div>
|
||||
</div>
|
||||
<span class="review-progress-label">Migrating {migrateProgress.done} / {migrateProgress.total}…</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="done-summary">
|
||||
<Check size={13} weight="bold" style="color:var(--color-success)" />
|
||||
<span class="done-label">{migratedCount} migrated{failedCount > 0 ? ` · ${failedCount} failed` : ""}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="entry-list">
|
||||
{#each entries as entry, idx}
|
||||
<div class="entry-row" class:entry-migrated={entry.status === "migrated"} class:entry-failed={entry.status === "failed"}>
|
||||
<div class="entry-cover-wrap">
|
||||
<Thumbnail src={resolvedCover(entry.manga.id, entry.manga.thumbnailUrl)} alt={entry.manga.title} class="entry-cover" />
|
||||
</div>
|
||||
|
||||
<div class="entry-info">
|
||||
<span class="entry-title">{entry.manga.title}</span>
|
||||
{#if entry.status === "found" && entry.match}
|
||||
<span class="entry-match">
|
||||
<Sparkle size={9} weight="fill" style="color:var(--accent-fg);flex-shrink:0" />
|
||||
{entry.match.title}
|
||||
<span class="entry-sim">{Math.round(entry.similarity * 100)}%</span>
|
||||
</span>
|
||||
{:else if entry.status === "no-match"}
|
||||
<span class="entry-no-match">No match found</span>
|
||||
{:else if entry.status === "searching"}
|
||||
<span class="entry-searching">Searching…</span>
|
||||
{:else if entry.status === "migrated"}
|
||||
<span class="entry-done">Migrated</span>
|
||||
{:else if entry.status === "failed"}
|
||||
<span class="entry-fail">{entry.error ?? "Failed"}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="entry-status">
|
||||
{#if entry.status === "searching"}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if entry.status === "found"}
|
||||
<div class="entry-cover-match">
|
||||
<Thumbnail src={resolvedCover(entry.match!.id, entry.match!.thumbnailUrl)} alt={entry.match!.title} class="entry-match-cover" />
|
||||
</div>
|
||||
{#if phase === "review"}
|
||||
<button class="entry-exclude-btn" onclick={() => excludeEntry(idx)} title="Exclude from migration">
|
||||
<X size={10} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if entry.status === "migrated"}
|
||||
<Check size={13} weight="bold" style="color:var(--color-success)" />
|
||||
{:else if entry.status === "failed"}
|
||||
<Warning size={13} weight="light" style="color:var(--color-error)" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if phase === "review" && searchProgress.done === searchProgress.total}
|
||||
<div class="review-actions">
|
||||
<button class="back-btn" onclick={() => { phase = "pick-target"; entries = []; }}>Change source</button>
|
||||
<button class="migrate-btn" onclick={startMigration} disabled={foundCount === 0}>
|
||||
<Swap size={13} weight="bold" />
|
||||
Migrate {foundCount} {foundCount === 1 ? "title" : "titles"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if phase === "done"}
|
||||
<div class="review-actions">
|
||||
<button class="migrate-btn" onclick={onDone}><Check size={13} weight="bold" /> Done</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
|
||||
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 560px; max-height: 84vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.source-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.source-icon-wrap { width: 36px; height: 36px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.source-icon-wrap.small { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||
:global(.src-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.source-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
.phase-label-row { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; }
|
||||
.phase-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
|
||||
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
.review-header { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.review-route { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.review-source { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.review-source-name { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: var(--weight-medium); }
|
||||
.review-progress-row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.review-progress-bar { flex: 1; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.review-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.review-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; flex-shrink: 0; }
|
||||
.done-summary { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.done-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.entry-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.entry-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast); }
|
||||
.entry-row:hover { background: var(--bg-raised); }
|
||||
.entry-migrated { opacity: 0.5; }
|
||||
.entry-failed { border-color: rgba(180,60,60,0.15); background: rgba(180,60,60,0.04); }
|
||||
.entry-cover-wrap { width: 28px; height: 42px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
:global(.entry-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.entry-info { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; overflow: hidden; }
|
||||
.entry-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-match { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-sim { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 0 4px; font-size: 9px; flex-shrink: 0; }
|
||||
.entry-no-match { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.entry-searching { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.entry-done { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-success); letter-spacing: var(--tracking-wide); }
|
||||
.entry-fail { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.entry-status { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.entry-cover-match { width: 24px; height: 36px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
:global(.entry-match-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.entry-exclude-btn { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.entry-exclude-btn:hover { color: var(--color-error); background: var(--bg-raised); }
|
||||
|
||||
.review-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.back-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrate-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -71,8 +71,8 @@
|
||||
|
||||
let activeDragKind: "tab" | null = $state(null);
|
||||
let dragInsertIdx: number = $state(-1);
|
||||
let dragTabId: number | null = $state(null);
|
||||
let dragOverTabId: number | null = $state(null);
|
||||
let dragTabId: string | null = $state(null);
|
||||
let dragOverTabId: string | null = $state(null);
|
||||
|
||||
const DT_TAB = "application/x-moku-tab";
|
||||
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 pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||
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);
|
||||
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
||||
if (!inOrder.has(id)) ordered.push(id);
|
||||
@@ -517,45 +518,54 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onTabDragStart(e: DragEvent, cat: Category) {
|
||||
activeDragKind = "tab"; dragTabId = cat.id;
|
||||
function onTabDragStart(e: DragEvent, id: string) {
|
||||
activeDragKind = "tab"; dragTabId = id;
|
||||
e.dataTransfer!.effectAllowed = "move";
|
||||
e.dataTransfer!.setData(DT_TAB, String(cat.id));
|
||||
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
|
||||
e.dataTransfer!.setData(DT_TAB, id);
|
||||
e.dataTransfer!.setData("text/plain", `tab:${id}`);
|
||||
}
|
||||
|
||||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
||||
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
|
||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
||||
dragOverTabId = cat.id;
|
||||
dragOverTabId = id;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
||||
}
|
||||
|
||||
function onTabDragLeave() { dragOverTabId = null; }
|
||||
|
||||
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
||||
async function onTabDrop(e: DragEvent, dropId: string) {
|
||||
e.preventDefault(); dragOverTabId = null;
|
||||
const insertAt = dragInsertIdx;
|
||||
dragInsertIdx = -1;
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||
const dragStrId = String(dragId);
|
||||
const tabs = [...visibleTabIds];
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
|
||||
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||
|
||||
const tabs = [...allTabIds];
|
||||
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);
|
||||
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length));
|
||||
tabs.splice(dest, 0, dragStrId);
|
||||
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
|
||||
tabs.splice(adjustedDest, 0, dragStrId);
|
||||
|
||||
updateSettings({ libraryPinnedTabOrder: tabs });
|
||||
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
||||
setCategories([...zeroCat, ...reordered]);
|
||||
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||
try {
|
||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos });
|
||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
|
||||
if (!dragIsBuiltin) {
|
||||
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||
try {
|
||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
|
||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||
}
|
||||
}
|
||||
|
||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
search: string;
|
||||
activeDragKind: "tab" | null;
|
||||
dragInsertIdx: number;
|
||||
dragTabId: number | null;
|
||||
dragOverTabId: number | null;
|
||||
dragTabId: string | null;
|
||||
dragOverTabId: string | null;
|
||||
sortPanelOpen: boolean;
|
||||
filterPanelOpen: boolean;
|
||||
tabsEl: HTMLDivElement;
|
||||
@@ -39,10 +39,10 @@
|
||||
onSortPanelToggle: () => void;
|
||||
onFilterPanelToggle: () => void;
|
||||
onOpenDownloadsFolder: () => void;
|
||||
onTabDragStart: (e: DragEvent, cat: Category) => void;
|
||||
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
|
||||
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||
onTabDragLeave: () => void;
|
||||
onTabDrop: (e: DragEvent, cat: Category) => void;
|
||||
onTabDrop: (e: DragEvent, id: string) => void;
|
||||
onTabDragEnd: () => void;
|
||||
}
|
||||
|
||||
@@ -100,20 +100,23 @@
|
||||
{#each visibleTabIds as id, idx}
|
||||
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||
{#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}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={tab === id}
|
||||
class:tab-dragging={cat && dragTabId === cat.id}
|
||||
draggable={!!cat && id !== String(completedCatId)}
|
||||
class:tab-dragging={isDraggable && dragTabId === id}
|
||||
draggable={isDraggable}
|
||||
onclick={() => onTabChange(id)}
|
||||
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
|
||||
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
|
||||
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
|
||||
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
|
||||
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
|
||||
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
|
||||
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
|
||||
ondragleave={isDraggable ? onTabDragLeave : undefined}
|
||||
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
|
||||
ondragend={isDraggable ? onTabDragEnd : undefined}
|
||||
>
|
||||
{#if id === "library"}<Books size={11} weight="bold" />
|
||||
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||
import { store, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
|
||||
let { ids, onClose }: {
|
||||
ids: Set<number>;
|
||||
@@ -42,6 +42,14 @@
|
||||
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
||||
|
||||
const mosaicCovers = $derived.by(() => {
|
||||
const idArr = [...ids].slice(0, 9);
|
||||
return idArr
|
||||
.map(id => store.library?.find(m => m.id === id))
|
||||
.filter(Boolean)
|
||||
.map(m => resolvedCover(m!.id, m!.thumbnailUrl));
|
||||
});
|
||||
|
||||
function apply() {
|
||||
for (const id of ids) {
|
||||
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
||||
@@ -60,11 +68,25 @@
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
{#if mosaicCovers.length > 0}
|
||||
<div class="mosaic" aria-hidden="true">
|
||||
{#each mosaicCovers.slice(0, 5) as src}
|
||||
<img class="mosaic-tile" {src} alt="" />
|
||||
{/each}
|
||||
{#if ids.size > 5}
|
||||
<span class="mosaic-overflow">+{ids.size - 5}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="header-text">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@@ -214,13 +236,37 @@
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
.modal-header { border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
|
||||
.header-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
padding: var(--sp-4) var(--sp-5); gap: var(--sp-3);
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
|
||||
.mosaic {
|
||||
display: flex; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mosaic-tile {
|
||||
width: 28px; height: 38px;
|
||||
object-fit: cover; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
margin-left: -6px; box-shadow: -1px 0 0 var(--bg-surface);
|
||||
}
|
||||
.mosaic-tile:first-child { margin-left: 0; }
|
||||
|
||||
.mosaic-overflow {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
margin-left: var(--sp-1); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import type { StripChapter } from "../lib/scrollHandler";
|
||||
@@ -20,6 +19,7 @@
|
||||
tapToToggleBar: boolean;
|
||||
pinchZoomEnabled: boolean;
|
||||
chapterEpoch: number;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGetZoom: () => number;
|
||||
onSetZoom: (z: number) => void;
|
||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||
@@ -32,7 +32,7 @@
|
||||
const {
|
||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
|
||||
tapToToggleBar, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
|
||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -211,6 +211,65 @@
|
||||
let stripDragStartY = 0;
|
||||
let stripScrollStart = 0;
|
||||
|
||||
let autoScrollPaused = false;
|
||||
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let midScrollActive = $state(false);
|
||||
let midScrollOriginY = $state(0);
|
||||
let midScrollOriginX = $state(0);
|
||||
let midScrollCurrentY = 0;
|
||||
let midScrollRaf: number | null = null;
|
||||
|
||||
// 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;
|
||||
midScrollOriginY = originY;
|
||||
midScrollOriginX = originX;
|
||||
midScrollDisplayLevel = 0;
|
||||
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
||||
const tick = () => {
|
||||
if (!midScrollActive || !containerEl) return;
|
||||
const dy = midScrollCurrentY - midScrollOriginY;
|
||||
const deadZone = 24;
|
||||
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
||||
const speed = Math.sign(dy) * excess * 0.12;
|
||||
containerEl.scrollTop += speed;
|
||||
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||
midScrollRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
midScrollRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stopMidScroll() {
|
||||
midScrollActive = false;
|
||||
midScrollDisplayLevel = 0;
|
||||
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||
}
|
||||
|
||||
function pauseAutoScroll() {
|
||||
autoScrollPaused = true;
|
||||
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
||||
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (style !== "longstrip" || !store.settings.autoScroll) return;
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (store.settings.autoScrollSpeed ?? 5) * 0.5;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
let pinch: PinchTracker | null = null;
|
||||
|
||||
$effect(() => {
|
||||
@@ -230,11 +289,21 @@
|
||||
|
||||
export function onInspectMouseDown(e: MouseEvent) {
|
||||
if ((e.target as Element).closest(".bar")) return;
|
||||
if (e.button === 1 && style === "longstrip") {
|
||||
e.preventDefault();
|
||||
if (midScrollActive) { stopMidScroll(); } else {
|
||||
// pause regular auto-scroll while mid-scroll is active
|
||||
store.settings.autoScroll = false;
|
||||
startMidScroll(e.clientY, e.clientX);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (style === "longstrip") {
|
||||
stripDragging = true;
|
||||
stripDragMoved = false;
|
||||
stripDragStartY = e.clientY;
|
||||
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||
pauseAutoScroll();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -249,6 +318,7 @@
|
||||
}
|
||||
|
||||
export function onInspectMouseMove(e: MouseEvent) {
|
||||
midScrollCurrentY = e.clientY;
|
||||
if (stripDragging) {
|
||||
const dy = e.clientY - stripDragStartY;
|
||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||
@@ -305,6 +375,7 @@
|
||||
export function handleWheel(e: WheelEvent) {
|
||||
if (style === "longstrip") {
|
||||
if (e.ctrlKey) { onWheel(e); }
|
||||
else pauseAutoScroll();
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey) { onWheel(e); return; }
|
||||
@@ -328,7 +399,10 @@
|
||||
}
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
if (style === "longstrip") {
|
||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||
return;
|
||||
}
|
||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||
onTap(e);
|
||||
@@ -346,6 +420,7 @@
|
||||
} else if (style !== "longstrip") {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
stopMidScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -355,20 +430,43 @@
|
||||
class="viewer"
|
||||
class:strip={style === "longstrip"}
|
||||
class:inspect-active={readerState.inspectScale > 1}
|
||||
class:midscroll-active={midScrollActive}
|
||||
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={handleTap}
|
||||
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
|
||||
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
||||
ondblclick={() => { if (tapToToggleBar) onToggleUi(); }}
|
||||
onmousedown={onInspectMouseDown}
|
||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||
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}
|
||||
<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 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 error}
|
||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||
@@ -376,7 +474,7 @@
|
||||
|
||||
{#key chapterEpoch}
|
||||
{#if style === "longstrip"}
|
||||
{#each flatPages as page, gi}
|
||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||
{@const src = resolvedSrc[gi]}
|
||||
{@const isLoaded = loadedSet.has(gi)}
|
||||
<div
|
||||
@@ -384,9 +482,9 @@
|
||||
use:observePage={gi}
|
||||
data-gi={gi}
|
||||
>
|
||||
{#if isLoaded}
|
||||
{#if isLoaded && src}
|
||||
<img
|
||||
src={src ?? ""}
|
||||
src={src}
|
||||
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||
data-local-page={page.localIndex + 1}
|
||||
data-chapter={page.chapterId}
|
||||
@@ -404,7 +502,9 @@
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="strip-placeholder"></div>
|
||||
<div class="strip-placeholder" 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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -413,7 +513,9 @@
|
||||
{:else if style === "fade" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" draggable="false" />
|
||||
<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>
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
|
||||
{/await}
|
||||
@@ -423,23 +525,31 @@
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i}
|
||||
{#each currentGroup as pg, i (pg)}
|
||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" 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>
|
||||
{:then src}
|
||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
<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}
|
||||
</div>
|
||||
|
||||
{:else if pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||
<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>
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||
{/await}
|
||||
@@ -450,7 +560,7 @@
|
||||
</div>
|
||||
|
||||
<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:focus { outline: none; }
|
||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||
@@ -471,14 +581,52 @@
|
||||
width: var(--effective-width, 100%);
|
||||
max-width: var(--effective-width, 100%);
|
||||
aspect-ratio: var(--aspect, 0.667);
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-loader {
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-loader-single {
|
||||
width: min(100%, var(--effective-width, 100%));
|
||||
max-width: var(--effective-width, 100%);
|
||||
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||
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:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||
: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-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; 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(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||
:global(.strip-gap) { margin-bottom: 8px; }
|
||||
|
||||
@@ -489,4 +637,74 @@
|
||||
|
||||
.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); }
|
||||
|
||||
.midscroll-bar {
|
||||
position: fixed;
|
||||
top: 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;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
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-stop:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-overlay);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -225,6 +225,7 @@
|
||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
||||
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) }); },
|
||||
toggleMarker: () => {
|
||||
if (currentPageMarkers.length > 0) {
|
||||
const first = currentPageMarkers[0];
|
||||
@@ -592,6 +593,7 @@
|
||||
fadingOut={readerState.fadingOut}
|
||||
{tapToToggleBar}
|
||||
{pinchZoomEnabled}
|
||||
{barPosition}
|
||||
onGetZoom={() => zoom}
|
||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
class:bar-left={barPosition === "left"}
|
||||
class:bar-right={barPosition === "right"}
|
||||
class:hidden={!uiVisible}
|
||||
data-tauri-drag-region={barPosition === "top" ? true : undefined}
|
||||
>
|
||||
<div class="bar-start">
|
||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||
@@ -177,7 +176,7 @@
|
||||
</button>
|
||||
|
||||
{#if !isVertical}
|
||||
<span class="bar-sep"></span>
|
||||
<span class="bar-sep" data-tauri-drag-region></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -187,6 +186,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isVertical}
|
||||
<div class="bar-drag-gap" data-tauri-drag-region></div>
|
||||
{/if}
|
||||
|
||||
<div class="bar-end">
|
||||
<div class="zoom-wrap">
|
||||
<div class="zoom-inline">
|
||||
@@ -393,12 +396,15 @@
|
||||
.bar-left { left: 0; border-right: 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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-end,
|
||||
.bar-right .bar-start,
|
||||
|
||||
@@ -178,6 +178,32 @@
|
||||
aria-checked={store.settings.autoNextChapter ?? false}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span class="toggle-label">Auto scroll</span>
|
||||
<button
|
||||
class="toggle"
|
||||
class:on={store.settings.autoScroll ?? false}
|
||||
onclick={() => updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) })}
|
||||
role="switch"
|
||||
aria-label="Auto scroll"
|
||||
aria-checked={store.settings.autoScroll ?? false}
|
||||
><span class="toggle-knob"></span></button>
|
||||
</label>
|
||||
{#if store.settings.autoScroll}
|
||||
<div class="speed-row">
|
||||
<span class="speed-label">Speed</span>
|
||||
<input
|
||||
type="range"
|
||||
class="zoom-slider"
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
value={store.settings.autoScrollSpeed ?? 5}
|
||||
oninput={(e) => updateSettings({ autoScrollSpeed: Number(e.currentTarget.value) })}
|
||||
/>
|
||||
<span class="speed-val">{store.settings.autoScrollSpeed ?? 5}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -760,4 +786,28 @@
|
||||
padding: var(--sp-2) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.speed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.speed-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.speed-val {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
min-width: 1.5ch;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -5,22 +5,22 @@
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Props {
|
||||
style: string;
|
||||
loading: boolean;
|
||||
rtl: boolean;
|
||||
sliderPage: number;
|
||||
sliderMax: number;
|
||||
sliderPct: number;
|
||||
lastPage: number;
|
||||
displayChapter: Chapter | null;
|
||||
currentBookmark: BookmarkEntry | undefined;
|
||||
style: string;
|
||||
loading: boolean;
|
||||
rtl: boolean;
|
||||
sliderPage: number;
|
||||
sliderMax: number;
|
||||
sliderPct: number;
|
||||
lastPage: number;
|
||||
displayChapter: Chapter | null;
|
||||
currentBookmark: BookmarkEntry | undefined;
|
||||
activeChapterMarkers: MarkerEntry[];
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
uiVisible: boolean;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGoPrev: () => void;
|
||||
onGoNext: () => void;
|
||||
onJumpToPage: (page: number) => void;
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
uiVisible: boolean;
|
||||
barPosition: "top" | "left" | "right";
|
||||
onGoPrev: () => void;
|
||||
onGoNext: () => void;
|
||||
onJumpToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -31,6 +31,25 @@
|
||||
}: Props = $props();
|
||||
|
||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||
|
||||
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
|
||||
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||
const vPct = $derived(`--pct:${sliderPct}%`);
|
||||
|
||||
function handleH(e: Event) {
|
||||
const raw = Number((e.target as HTMLInputElement).value);
|
||||
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
|
||||
}
|
||||
|
||||
function handleV(e: Event) {
|
||||
onJumpToPage(Number((e.target as HTMLInputElement).value));
|
||||
}
|
||||
|
||||
function markerPct(pageNumber: number, forRtl = false): number {
|
||||
if (sliderMax <= 1) return 0;
|
||||
const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber;
|
||||
return ((ord - 1) / (sliderMax - 1)) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isVertical}
|
||||
@@ -43,44 +62,35 @@
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="slider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
onmouseleave={() => readerState.sliderHover = false}
|
||||
>
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
|
||||
<input
|
||||
type="range"
|
||||
class="h-range"
|
||||
style={hPct}
|
||||
min={1}
|
||||
max={sliderMax}
|
||||
value={hValue}
|
||||
oninput={handleH}
|
||||
onmousedown={() => readerState.sliderDragging = true}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
/>
|
||||
|
||||
<div class="slider-markers" aria-hidden="true">
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
<div class="slider-checkpoint bookmark-checkpoint"
|
||||
style="left:{markerPct(currentBookmark.pageNumber, rtl)}%"
|
||||
title="Bookmark: Page {currentBookmark.pageNumber}">
|
||||
</div>
|
||||
{/if}
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
<div class="slider-checkpoint marker-checkpoint"
|
||||
style="left:{markerPct(m.pageNumber, rtl)}%;background:{MARKER_COLOR_HEX[m.color]}"
|
||||
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
|
||||
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="slider-tooltip" style="left:{sliderPct}%">
|
||||
@@ -100,42 +110,37 @@
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="vslider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
onmouseleave={() => readerState.sliderHover = false}
|
||||
>
|
||||
<div class="vslider-track-bg">
|
||||
<div class="vslider-fill" style="height:{sliderPct}%"></div>
|
||||
<input
|
||||
type="range"
|
||||
class="v-range"
|
||||
style={vPct}
|
||||
min={1}
|
||||
max={sliderMax}
|
||||
value={sliderPage}
|
||||
oninput={handleV}
|
||||
onmousedown={() => readerState.sliderDragging = true}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
/>
|
||||
|
||||
<div class="vslider-markers" aria-hidden="true">
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint bookmark-checkpoint"
|
||||
style="top:{bPct}%"
|
||||
title="Bookmark: Page {currentBookmark.pageNumber}">
|
||||
</div>
|
||||
{/if}
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint marker-checkpoint"
|
||||
style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}"
|
||||
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="vslider-thumb" style="top:{sliderPct}%"></div>
|
||||
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||
<div class="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
|
||||
@@ -155,101 +160,99 @@
|
||||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; }
|
||||
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; }
|
||||
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; }
|
||||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
|
||||
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); pointer-events: none; z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); transition: transform var(--t-fast); }
|
||||
.slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; }
|
||||
|
||||
.vbar-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
.h-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: var(--sp-2) 0;
|
||||
transition: opacity 0.25s ease;
|
||||
pointer-events: none;
|
||||
height: 34px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.h-range::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%));
|
||||
border-radius: 2px;
|
||||
transition: height 0.15s ease, background 0.05s linear;
|
||||
}
|
||||
.h-range:hover::-webkit-slider-runnable-track,
|
||||
.h-range:active::-webkit-slider-runnable-track { height: 5px; }
|
||||
.h-range::-moz-range-track { height: 3px; background: var(--border-strong); border-radius: 2px; transition: height 0.15s ease; }
|
||||
.h-range::-moz-range-progress { height: 3px; background: var(--accent-fg); border-radius: 2px; transition: height 0.15s ease; }
|
||||
.h-range:hover::-moz-range-track, .h-range:active::-moz-range-track { height: 5px; }
|
||||
.h-range:hover::-moz-range-progress, .h-range:active::-moz-range-progress { height: 5px; }
|
||||
.h-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
|
||||
margin-top: -4.5px;
|
||||
transition: transform var(--t-fast);
|
||||
}
|
||||
.h-range:hover::-webkit-slider-thumb,
|
||||
.h-range:active::-webkit-slider-thumb { transform: scale(1.3); }
|
||||
.h-range::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
transition: transform var(--t-fast);
|
||||
}
|
||||
.h-range:hover::-moz-range-thumb,
|
||||
.h-range:active::-moz-range-thumb { transform: scale(1.3); }
|
||||
|
||||
.slider-markers { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
|
||||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); }
|
||||
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; }
|
||||
.vbar-progress.hidden { opacity: 0; }
|
||||
|
||||
.vslider-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
.vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; }
|
||||
|
||||
.v-range {
|
||||
-webkit-appearance: slider-vertical;
|
||||
appearance: slider-vertical;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
width: 34px;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
margin: var(--sp-1) 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.vslider-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
}
|
||||
.vslider-fill {
|
||||
width: 100%;
|
||||
background: var(--accent-fg);
|
||||
border-radius: 3px;
|
||||
transition: height 0.05s linear;
|
||||
}
|
||||
.vslider-thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
.v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; }
|
||||
.v-range:hover::-webkit-slider-runnable-track,
|
||||
.v-range:active::-webkit-slider-runnable-track { width: 7px; }
|
||||
.v-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
|
||||
margin-left: -4.5px;
|
||||
transition: transform var(--t-fast);
|
||||
}
|
||||
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; }
|
||||
.vslider-checkpoint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.vslider-tooltip {
|
||||
position: absolute;
|
||||
left: calc(100% + 6px);
|
||||
transform: translateY(-50%);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.vslider-tooltip.tooltip-right {
|
||||
left: auto;
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.v-range:hover::-webkit-slider-thumb,
|
||||
.v-range:active::-webkit-slider-thumb { transform: scale(1.3); }
|
||||
|
||||
.vslider-markers { position: absolute; inset: 0; pointer-events: none; }
|
||||
.vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; }
|
||||
.vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ export interface ReaderKeyActions {
|
||||
openSettings: () => void;
|
||||
toggleBookmark: () => void;
|
||||
toggleMarker: () => void;
|
||||
toggleAutoScroll: () => void;
|
||||
chapterNext: () => void;
|
||||
chapterPrev: () => void;
|
||||
closePopovers: () => boolean;
|
||||
@@ -55,5 +56,6 @@ export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardE
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
|
||||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
|
||||
else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); }
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||
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 { 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 {
|
||||
store, addToast, openReader, setActiveManga,
|
||||
@@ -321,7 +321,7 @@
|
||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
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 });
|
||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||
@@ -329,7 +329,10 @@
|
||||
|
||||
async function enqueueMultiple(chapterIds: number[]) {
|
||||
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` });
|
||||
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 unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||
{ 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 },
|
||||
{ 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)) },
|
||||
@@ -705,7 +708,7 @@
|
||||
{/if}
|
||||
|
||||
{#if autoOpen && store.activeManga}
|
||||
<AutomationPanel mangaId={store.activeManga.id} onClose={() => autoOpen = false} />
|
||||
<AutomationPanel mangaId={store.activeManga.id} manga={store.activeManga} onClose={() => autoOpen = false} />
|
||||
{/if}
|
||||
|
||||
{#if markersOpen && store.activeManga}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { getPref, setPref } from "../lib/mangaPrefs";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
import type { Manga } from "@types/index";
|
||||
|
||||
let { mangaId, onClose }: {
|
||||
let { mangaId, manga: mangaProp = null, onClose }: {
|
||||
mangaId: number;
|
||||
manga?: Manga | null;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -35,9 +40,19 @@
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key);
|
||||
const defaults = $derived(store.settings.automationDefaults);
|
||||
|
||||
function get<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
|
||||
const pref = getPref(mangaId, key);
|
||||
if (pref !== undefined) return pref;
|
||||
return (defaults as MangaPrefs | undefined)?.[key] ?? getPref(mangaId, key);
|
||||
}
|
||||
|
||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
|
||||
|
||||
const manga = $derived(store.library?.find(m => m.id === mangaId) ?? mangaProp);
|
||||
const coverSrc = $derived(manga ? resolvedCover(manga.id, manga.thumbnailUrl) : null);
|
||||
|
||||
function onBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
@@ -46,135 +61,150 @@
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Automation</span>
|
||||
<span class="modal-subtitle">Per-series rules</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||
<div class="cover-col">
|
||||
{#if coverSrc}
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={coverSrc} alt={manga?.title} class="cover" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cover-placeholder"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="section-label">Downloads</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Auto-download new chapters</span>
|
||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<div class="title-block">
|
||||
<span class="title">{manga?.title ?? "Automation"}</span>
|
||||
<span class="subtitle">Per-series rules</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("autoDownload")}
|
||||
onclick={() => set("autoDownload", !get("autoDownload"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||
<X size={16} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Download ahead</span>
|
||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("downloadAhead") === opt.value}
|
||||
onclick={() => set("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-body">
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Max chapters to keep</span>
|
||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
<p class="section-label">Downloads</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Auto-download new chapters</span>
|
||||
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("autoDownload")}
|
||||
onclick={() => set("autoDownload", !get("autoDownload"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
||||
onclick={() => set("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">On Read</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Delete after reading</span>
|
||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("deleteOnRead")}
|
||||
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if get("deleteOnRead")}
|
||||
<div class="auto-row auto-row-sub">
|
||||
<span class="auto-label">Delete delay</span>
|
||||
<div class="auto-row auto-row-col">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Download ahead</span>
|
||||
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
||||
onclick={() => set("deleteDelayHours", opt.value)}
|
||||
class:auto-chip-on={get("downloadAhead") === opt.value}
|
||||
onclick={() => set("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Updates</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Pause updates</span>
|
||||
<span class="auto-desc">Skip this series during global refresh</span>
|
||||
<div class="auto-row auto-row-col">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Max chapters to keep</span>
|
||||
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
||||
onclick={() => set("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("pauseUpdates")}
|
||||
aria-label="Pause updates"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("pauseUpdates")}
|
||||
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">On Read</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Delete after reading</span>
|
||||
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("deleteOnRead")}
|
||||
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if get("deleteOnRead")}
|
||||
<div class="auto-row auto-row-sub">
|
||||
<span class="auto-label">Delete delay</span>
|
||||
<div class="auto-chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
||||
onclick={() => set("deleteDelayHours", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="section-label">Updates</p>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Pause updates</span>
|
||||
<span class="auto-desc">Skip this series during global refresh</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={get("pauseUpdates")}
|
||||
aria-label="Pause updates"
|
||||
class="auto-toggle"
|
||||
class:auto-toggle-on={get("pauseUpdates")}
|
||||
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
||||
><span class="auto-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="auto-row auto-row-col">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Refresh interval</span>
|
||||
<span class="auto-desc">How often to check for new chapters</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("refreshInterval") === opt.value}
|
||||
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="auto-row">
|
||||
<div class="auto-info">
|
||||
<span class="auto-label">Refresh interval</span>
|
||||
<span class="auto-desc">How often to check for new chapters</span>
|
||||
</div>
|
||||
<div class="auto-chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="auto-chip"
|
||||
class:auto-chip-on={get("refreshInterval") === opt.value}
|
||||
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,31 +217,84 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||
max-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
display: flex; flex-direction: row;
|
||||
width: 600px; max-width: calc(100vw - var(--sp-6));
|
||||
height: 480px; max-height: 85vh;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
.cover-col {
|
||||
width: 200px; flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
padding: var(--sp-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-wrap { position: relative; width: 100%; flex: 1; min-height: 0; }
|
||||
|
||||
:global(.cover) {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover; object-position: center top;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--bg-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; flex-shrink: 0;
|
||||
border-radius: var(--radius-sm); color: var(--text-faint);
|
||||
background: none; border: none; cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.modal-body {
|
||||
.content-body {
|
||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
}
|
||||
.modal-body::-webkit-scrollbar { display: none; }
|
||||
.content-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
@@ -222,7 +305,9 @@
|
||||
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.auto-row-col { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
|
||||
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
|
||||
|
||||
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
@@ -232,7 +317,7 @@
|
||||
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||
|
||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
|
||||
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { UPDATE_MANGA } from "@api/mutations/manga";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { UPDATE_MANGA } from "@api/mutations/manga";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
|
||||
import { store } from "@store/state.svelte";
|
||||
import type { Manga, Chapter } from "@types";
|
||||
import type { Source } from "@types";
|
||||
import type { Manga, Chapter, Source } from "@types";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
@@ -20,6 +20,7 @@
|
||||
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
|
||||
|
||||
type Step = "source" | "search" | "confirm";
|
||||
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
|
||||
|
||||
interface Match {
|
||||
manga: Manga;
|
||||
@@ -39,16 +40,15 @@
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
|
||||
let step: Step = $state("source");
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
|
||||
let selectedLang: string = $state("all");
|
||||
let selectedLang = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const stepIdx = $derived(STEPS.indexOf(step));
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
@@ -56,21 +56,7 @@
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const chips = Array.from(langStripEl.children) as HTMLElement[];
|
||||
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
|
||||
if (dir === 1) {
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
|
||||
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
const visibleSources = $derived.by(() => {
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
@@ -80,19 +66,30 @@
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
let loadingMatchId: number | null = $state(null);
|
||||
let migrating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
let loadingMatchId: number | null = $state(null);
|
||||
let migrating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
const readCount = $derived(currentChapters.filter(c => c.isRead).length);
|
||||
const totalCount = $derived(currentChapters.length);
|
||||
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
|
||||
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
|
||||
const stepIdx = $derived(STEPS.indexOf(step));
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const chips = Array.from(langStripEl.children) as HTMLElement[];
|
||||
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
|
||||
if (dir === 1) {
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
|
||||
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
@@ -136,7 +133,7 @@
|
||||
async function selectMatch(m: Manga, similarity: number) {
|
||||
loadingMatchId = m.id; error = null;
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
const chapters = d.fetchChapters.chapters;
|
||||
const matchReadCount = chapters.filter(c => {
|
||||
const old = currentChapters.find(o => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
@@ -158,27 +155,25 @@
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
const oldByNum = new Map(currentChapters.map(c => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||
|
||||
for (const nc of newChapters) {
|
||||
const old = oldByNum.get(Math.round(nc.chapterNumber * 100));
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
if (toMarkRead.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
if (toMarkBookmarked.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
onMigrated({ ...newManga, inLibrary: true });
|
||||
@@ -189,15 +184,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="modal-title-label">Migrate source</span>
|
||||
<span class="modal-title-manga">{manga.title}</span>
|
||||
<div class="manga-context">
|
||||
<div class="manga-context-cover">
|
||||
<Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="ctx-cover" />
|
||||
</div>
|
||||
<div class="manga-context-info">
|
||||
<span class="modal-eyebrow">Migrate source</span>
|
||||
<span class="modal-title">{manga.title}</span>
|
||||
{#if manga.source?.displayName}
|
||||
<span class="modal-source">{manga.source.displayName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
@@ -209,7 +211,7 @@
|
||||
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
|
||||
</span>
|
||||
<span class="step-label">
|
||||
{st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"}
|
||||
{st === "source" ? "Pick source" : st === "search" ? (selectedSource?.displayName ?? "Search") : "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -241,11 +243,10 @@
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button
|
||||
class="source-row"
|
||||
class:source-row-active={selectedSource?.id === src.id}
|
||||
onclick={() => pickSource(src)}>
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<button class="source-row" class:source-row-active={selectedSource?.id === src.id} onclick={() => pickSource(src)}>
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<div class="source-info">
|
||||
<span class="source-name">{src.displayName}</span>
|
||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
@@ -260,7 +261,9 @@
|
||||
<div class="search-step">
|
||||
{#if selectedSource}
|
||||
<div class="search-context">
|
||||
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<div class="source-icon-wrap" style="width:20px;height:20px;border-radius:var(--radius-sm)">
|
||||
<Thumbnail src={selectedSource.iconUrl} alt={selectedSource.name} class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||
</div>
|
||||
@@ -274,7 +277,7 @@
|
||||
bind:value={query}
|
||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
use:focusOnMount />
|
||||
autofocus />
|
||||
</div>
|
||||
<button class="search-btn"
|
||||
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
@@ -291,22 +294,20 @@
|
||||
|
||||
<div class="results">
|
||||
{#if searching}
|
||||
{#each Array(6) as _}
|
||||
{#each Array(5) as _}
|
||||
<div class="sk-result">
|
||||
<div class="skeleton sk-cover"></div>
|
||||
<div class="sk-meta">
|
||||
<div class="skeleton sk-title"></div>
|
||||
<div class="skeleton sk-title" style="width:40%"></div>
|
||||
<div class="skeleton sk-line" style="width:60%"></div>
|
||||
<div class="skeleton sk-line" style="width:35%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each results as { manga: m, similarity }, idx}
|
||||
<button class="result-row"
|
||||
onclick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<button class="result-row" onclick={() => selectMatch(m, similarity)} disabled={loadingMatchId !== null}>
|
||||
<div class="result-cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
|
||||
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{m.title}</span>
|
||||
@@ -315,17 +316,17 @@
|
||||
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
|
||||
{/if}
|
||||
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
|
||||
<span class="sim-label">{Math.round(similarity * 100)}% match</span>
|
||||
<span class="sim-label">{Math.round(similarity * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if loadingMatchId === m.id}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
|
||||
{:else}
|
||||
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.5" />
|
||||
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if results.length === 0 && !error}
|
||||
{#if results.length === 0 && !error && !searching}
|
||||
<div class="centered">
|
||||
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
@@ -339,18 +340,18 @@
|
||||
<div class="confirm-row">
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
|
||||
<Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
|
||||
</div>
|
||||
<p class="confirm-title">{manga.title}</p>
|
||||
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span class="confirm-tag">Current</span>
|
||||
</div>
|
||||
<div class="confirm-divider">
|
||||
<ArrowRight size={16} weight="light" class="confirm-arrow" />
|
||||
<div class="confirm-arrow-wrap">
|
||||
<ArrowRight size={18} weight="light" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||
<Thumbnail src={resolvedCover(selectedMatch.manga.id, selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||
</div>
|
||||
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
@@ -378,8 +379,8 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Read progress to carry over</span>
|
||||
<span class="stat-val">{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
<span class="stat-label">Read progress to carry</span>
|
||||
<span class="stat-val">{selectedMatch.readCount} / {readCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -413,54 +414,60 @@
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
|
||||
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.modal-title { display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-title-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title-manga { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.manga-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.manga-context-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
:global(.ctx-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.manga-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.modal-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.4; transition: opacity var(--t-base); }
|
||||
.step + .step::before { content: "›"; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); }
|
||||
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.35; transition: opacity var(--t-base); }
|
||||
.step + .step::before { content: "›"; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); opacity: 0.5; }
|
||||
.step-active { opacity: 1; }
|
||||
.step-done { opacity: 0.6; }
|
||||
.step-done { opacity: 0.55; }
|
||||
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
|
||||
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||
.step-active .step-label { color: var(--text-secondary); }
|
||||
|
||||
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
:global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.source-icon-wrap { width: 28px; height: 28px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); }
|
||||
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
||||
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; scroll-behavior: smooth; }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||
:global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||
:global(.search-context-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
||||
.search-context-change:hover { opacity: 0.75; }
|
||||
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; opacity: 0.8; transition: opacity var(--t-base); }
|
||||
.search-context-change:hover { opacity: 1; }
|
||||
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
|
||||
.search-bar:focus-within { border-color: var(--border-strong); }
|
||||
@@ -470,8 +477,9 @@
|
||||
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
|
||||
.search-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.search-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
@@ -480,45 +488,46 @@
|
||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.sim-bar { width: 48px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; display: inline-block; }
|
||||
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.2s ease; }
|
||||
.sim-bar { width: 40px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; }
|
||||
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
|
||||
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
|
||||
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); }
|
||||
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-title { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); }
|
||||
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-line { height: 12px; border-radius: var(--radius-sm); }
|
||||
|
||||
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
|
||||
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
||||
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
||||
.confirm-row { display: flex; align-items: flex-start; justify-content: center; gap: var(--sp-3); }
|
||||
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 150px; }
|
||||
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
:global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
||||
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
:global(.confirm-arrow) { color: var(--text-faint); }
|
||||
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
|
||||
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.confirm-arrow-wrap { display: flex; align-items: center; padding-top: 48px; flex-shrink: 0; }
|
||||
|
||||
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
.stat-good { color: var(--color-success) !important; }
|
||||
.stat-warn { color: #d97706 !important; }
|
||||
.stat-bad { color: var(--color-error) !important; }
|
||||
.stat-bad { color: var(--color-error) !important; }
|
||||
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); }
|
||||
|
||||
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); flex-shrink: 0; }
|
||||
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; }
|
||||
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.back-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrate-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); }
|
||||
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); flex-shrink: 0; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck } from "phosphor-svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from "phosphor-svelte";
|
||||
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
|
||||
import { eventToKeybind } from "@core/keybinds/keybindEngine";
|
||||
import type { Keybinds } from "@types/settings";
|
||||
@@ -19,16 +19,18 @@
|
||||
import ContentSettings from "../sections/ContentSettings.svelte";
|
||||
import AboutSettings from "../sections/AboutSettings.svelte";
|
||||
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
|
||||
import AutomationSettings from "../sections/AutomationSettings.svelte";
|
||||
|
||||
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
|
||||
let { onOpenThemeEditor }: Props = $props();
|
||||
|
||||
type Tab = "general"|"appearance"|"reader"|"library"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
|
||||
type Tab = "general"|"appearance"|"reader"|"library"|"automation"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
|
||||
const TABS: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "general", label: "General", icon: Gear },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush },
|
||||
{ id: "reader", label: "Reader", icon: Book },
|
||||
{ id: "library", label: "Library", icon: Image },
|
||||
{ id: "automation", label: "Automation", icon: Robot },
|
||||
{ id: "performance", label: "Performance", icon: Sliders },
|
||||
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
||||
@@ -60,7 +62,7 @@
|
||||
}
|
||||
|
||||
function close() { setSettingsOpen(false); }
|
||||
1
|
||||
|
||||
let listeningKey: keyof Keybinds | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
@@ -163,6 +165,8 @@
|
||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "library"}
|
||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "automation"}
|
||||
<AutomationSettings />
|
||||
{:else if tab === "performance"}
|
||||
<PerformanceSettings />
|
||||
{:else if tab === "keybinds"}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { ArrowCounterClockwise, LockSimple, Warning } from "phosphor-svelte";
|
||||
import { store, updateSettings, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
];
|
||||
|
||||
const MAX_KEEP_OPTIONS = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
{ value: 25, label: "25" },
|
||||
];
|
||||
|
||||
const DELETE_DELAY_OPTIONS = [
|
||||
{ value: 0, label: "Now" },
|
||||
{ value: 24, label: "1 day" },
|
||||
{ value: 168, label: "1 week" },
|
||||
];
|
||||
|
||||
const REFRESH_INTERVAL_OPTIONS = [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
type GlobalDefaults = Omit<MangaPrefs, "refreshInterval"> & {
|
||||
refreshInterval: "daily" | "weekly" | "manual";
|
||||
};
|
||||
|
||||
const fallback: GlobalDefaults = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
maxKeepChapters: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "weekly",
|
||||
};
|
||||
|
||||
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
|
||||
return (store.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key];
|
||||
}
|
||||
|
||||
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
|
||||
updateSettings({
|
||||
automationDefaults: {
|
||||
...(store.settings.automationDefaults ?? fallback),
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const enforceGlobal = $derived(store.settings.automationEnforceGlobal ?? false);
|
||||
|
||||
function toggleEnforce() {
|
||||
updateSettings({ automationEnforceGlobal: !enforceGlobal });
|
||||
}
|
||||
|
||||
const customCount = $derived(
|
||||
Object.keys(store.mangaPrefs ?? {}).filter((id) => {
|
||||
const prefs = (store.mangaPrefs as Record<string, Partial<MangaPrefs>>)[id];
|
||||
return prefs && Object.keys(prefs).length > 0;
|
||||
}).length
|
||||
);
|
||||
|
||||
let confirmReset = $state(false);
|
||||
|
||||
function resetAllCustoms() {
|
||||
if (!confirmReset) { confirmReset = true; return; }
|
||||
const ids = Object.keys(store.mangaPrefs ?? {});
|
||||
const blank = { ...DEFAULT_MANGA_PREFS };
|
||||
for (const id of ids) {
|
||||
for (const key of Object.keys(blank) as (keyof MangaPrefs)[]) {
|
||||
// setPref(Number(id), key, blank[key] as any)
|
||||
}
|
||||
}
|
||||
updateSettings({ _resetMangaPrefs: Date.now() } as any);
|
||||
confirmReset = false;
|
||||
}
|
||||
|
||||
function cancelReset() { confirmReset = false; }
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Behaviour</p>
|
||||
<div class="s-section-body">
|
||||
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Enable automation</span>
|
||||
<span class="s-desc">Allow per-series and global automation rules to run</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={store.settings.automationEnabled ?? false}
|
||||
aria-label="Enable automation"
|
||||
class="s-toggle"
|
||||
class:on={store.settings.automationEnabled ?? false}
|
||||
onclick={() => updateSettings({ automationEnabled: !(store.settings.automationEnabled ?? false) })}
|
||||
><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Enforce global defaults</span>
|
||||
<span class="s-desc">Ignore per-series overrides — all series use the global settings below</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={enforceGlobal}
|
||||
aria-label="Enforce global defaults"
|
||||
class="s-toggle"
|
||||
class:on={enforceGlobal}
|
||||
onclick={toggleEnforce}
|
||||
><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
|
||||
{#if enforceGlobal}
|
||||
<div class="s-banner s-banner-info enforce-banner">
|
||||
<LockSimple size={12} weight="fill" />
|
||||
<span>Per-series overrides are paused. Disable enforce to allow custom rules.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Global Defaults</p>
|
||||
<div class="s-section-body">
|
||||
|
||||
<p class="sub-head">Downloads</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Auto-download new chapters</span>
|
||||
<span class="s-desc">Queue new chapters when a series refreshes</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getGlobal("autoDownload")}
|
||||
aria-label="Auto-download new chapters"
|
||||
class="s-toggle"
|
||||
class:on={getGlobal("autoDownload")}
|
||||
onclick={() => setGlobal("autoDownload", !getGlobal("autoDownload"))}
|
||||
><span class="s-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Download ahead</span>
|
||||
<span class="s-desc">Pre-fetch chapters while reading</span>
|
||||
</div>
|
||||
<div class="chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button
|
||||
class="s-preset"
|
||||
class:active={getGlobal("downloadAhead") === opt.value}
|
||||
onclick={() => setGlobal("downloadAhead", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Max chapters to keep</span>
|
||||
<span class="s-desc">Delete oldest downloads when limit is exceeded</span>
|
||||
</div>
|
||||
<div class="chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button
|
||||
class="s-preset"
|
||||
class:active={getGlobal("maxKeepChapters") === opt.value}
|
||||
onclick={() => setGlobal("maxKeepChapters", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="sub-head sub-head-rule">On Read</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Delete after reading</span>
|
||||
<span class="s-desc">Remove download when a chapter is marked read</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={getGlobal("deleteOnRead")}
|
||||
aria-label="Delete after reading"
|
||||
class="s-toggle"
|
||||
class:on={getGlobal("deleteOnRead")}
|
||||
onclick={() => setGlobal("deleteOnRead", !getGlobal("deleteOnRead"))}
|
||||
><span class="s-toggle-thumb"></span></button>
|
||||
</div>
|
||||
|
||||
{#if getGlobal("deleteOnRead")}
|
||||
<div class="s-row chip-row sub-row">
|
||||
<span class="s-label">Delete delay</span>
|
||||
<div class="chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button
|
||||
class="s-preset"
|
||||
class:active={getGlobal("deleteDelayHours") === opt.value}
|
||||
onclick={() => setGlobal("deleteDelayHours", opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="sub-head sub-head-rule">Updates</p>
|
||||
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Default refresh interval</span>
|
||||
<span class="s-desc">How often series check for new chapters by default</span>
|
||||
</div>
|
||||
<div class="chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button
|
||||
class="s-preset"
|
||||
class:active={getGlobal("refreshInterval") === opt.value}
|
||||
onclick={() => setGlobal("refreshInterval", opt.value as GlobalDefaults["refreshInterval"])}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Custom Overrides</p>
|
||||
<div class="s-section-body">
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Series with custom rules</span>
|
||||
<span class="s-desc">Per-series settings set via the series automation panel</span>
|
||||
</div>
|
||||
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Reset all custom rules</span>
|
||||
<span class="s-desc">Revert every series to the global defaults above</span>
|
||||
</div>
|
||||
{#if confirmReset}
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}>
|
||||
<Warning size={11} weight="fill" /> Confirm reset
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelReset}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}>
|
||||
<ArrowCounterClockwise size={11} weight="regular" /> Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.enforce-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sub-head {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-4) 0;
|
||||
}
|
||||
|
||||
.sub-head-rule {
|
||||
border-top: 1px solid var(--border-dim);
|
||||
padding-top: var(--sp-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
align-items: flex-start;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chip-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sub-row {
|
||||
padding-left: calc(var(--sp-4) + var(--sp-2));
|
||||
border-left: 2px solid var(--border-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { cache } from "@core/cache/index";
|
||||
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from "@core/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
|
||||
@@ -12,13 +13,69 @@
|
||||
let appVersion = $state("…");
|
||||
let helloAvailable = $state<boolean | null>(null);
|
||||
let helloBusy = $state(false);
|
||||
let authStatus = $state<UiAuthDebugStatus | null>(null);
|
||||
let authRefreshBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
|
||||
refreshPerfMetrics();
|
||||
refreshAuthStatus();
|
||||
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() {
|
||||
let entries = 0, oldest: number | null = null, newest: number | null = null;
|
||||
const foundKeys: string[] = [];
|
||||
@@ -75,7 +132,7 @@
|
||||
<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-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({
|
||||
kind,
|
||||
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)">
|
||||
<span class="s-desc">3D tilt cards — hover to preview</span>
|
||||
<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>
|
||||
<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>
|
||||
@@ -159,4 +216,32 @@
|
||||
</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>
|
||||
@@ -6,13 +6,15 @@
|
||||
import type { Category } from "@types";
|
||||
import { store, updateSettings, setCategories } from "@store/state.svelte";
|
||||
|
||||
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
||||
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
||||
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
||||
const orderedCatIds = $derived.by(() => {
|
||||
const order = store.settings.libraryPinnedTabOrder ?? [];
|
||||
const known = new Set(sortedCatIds);
|
||||
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
|
||||
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
||||
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
||||
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
||||
|
||||
const orderedAllIds = $derived.by(() => {
|
||||
const order = store.settings.libraryPinnedTabOrder ?? [];
|
||||
const allIds = ["library", "downloaded", ...sortedCatIds];
|
||||
const known = new Set(allIds);
|
||||
return [...new Set([...order.filter(id => known.has(id)), ...allIds])];
|
||||
});
|
||||
|
||||
let catsLoading = $state(false);
|
||||
@@ -21,9 +23,9 @@
|
||||
let editingId = $state<number | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
let dragId = $state<number | null>(null);
|
||||
let dragOverId = $state<number | null>(null);
|
||||
let dropPosition = $state<"above" | "below" | null>(null);
|
||||
let dragStrId = $state<string | null>(null);
|
||||
let dragOverStrId = $state<string | null>(null);
|
||||
let dropPosition = $state<"above" | "below" | null>(null);
|
||||
|
||||
function isHidden(id: string) {
|
||||
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
|
||||
@@ -92,57 +94,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyReorder(fromId: number, toId: number) {
|
||||
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 fromIdx = sortable.findIndex(c => c.id === fromId);
|
||||
const toIdx = sortable.findIndex(c => c.id === toId);
|
||||
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;
|
||||
const reordered = [...sortable];
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||
base.splice(fromIdx, 1);
|
||||
base.splice(toIdx, 0, fromStrId);
|
||||
updateSettings({ libraryPinnedTabOrder: base });
|
||||
|
||||
const catIds = reordered.map(c => String(c.id));
|
||||
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);
|
||||
setCategories([
|
||||
...zeroCat,
|
||||
...updated.sort((a, b) => a.order - b.order).map(fresh => {
|
||||
const existing = store.categories.find(c => c.id === fresh.id);
|
||||
return existing ? { ...existing, ...fresh } : fresh;
|
||||
}),
|
||||
]);
|
||||
} catch (e: any) {
|
||||
catsError = e?.message ?? "Failed to reorder";
|
||||
await loadCategories();
|
||||
const fromNumId = Number(fromStrId);
|
||||
if (!isNaN(fromNumId) && fromStrId !== "library" && fromStrId !== "downloaded") {
|
||||
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 sFromIdx = sortable.findIndex(c => c.id === fromNumId);
|
||||
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId);
|
||||
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
|
||||
const reordered = [...sortable];
|
||||
const [moved] = reordered.splice(sFromIdx, 1);
|
||||
reordered.splice(sToIdx, 0, moved);
|
||||
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||
gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromNumId, position: sToIdx + 1 })
|
||||
.then(res => {
|
||||
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||
setCategories([
|
||||
...zeroCat,
|
||||
...updated.sort((a, b) => a.order - b.order).map(fresh => {
|
||||
const existing = store.categories.find(c => c.id === fresh.id);
|
||||
return existing ? { ...existing, ...fresh } : fresh;
|
||||
}),
|
||||
]);
|
||||
})
|
||||
.catch(async (e: any) => {
|
||||
catsError = e?.message ?? "Failed to reorder";
|
||||
await loadCategories();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent, id: number) {
|
||||
dragId = id;
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); }
|
||||
function onDragStart(e: DragEvent, id: string) {
|
||||
dragStrId = 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();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
if (dragId === id) return;
|
||||
dragOverId = id;
|
||||
if (dragStrId === id) return;
|
||||
dragOverStrId = id;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, id: number) {
|
||||
function onDrop(e: DragEvent, id: string) {
|
||||
e.preventDefault();
|
||||
if (dragId !== null && dragId !== id) applyReorder(dragId, id);
|
||||
dragId = null; dragOverId = null; dropPosition = null;
|
||||
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id);
|
||||
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(); }
|
||||
|
||||
@@ -166,96 +180,94 @@
|
||||
{#if catsLoading}
|
||||
<p class="s-empty">Loading folders…</p>
|
||||
{:else}
|
||||
<div class="s-folder-row s-folder-row-static">
|
||||
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
|
||||
<span class="s-folder-name s-folder-name-static">Saved</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
|
||||
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
|
||||
{#each orderedAllIds as id}
|
||||
{@const isBuiltin = id === "library" || id === "downloaded"}
|
||||
{@const isCompleted = id === completedId}
|
||||
{@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)}
|
||||
{@const hidden = isHidden(id)}
|
||||
|
||||
<div class="s-folder-row s-folder-row-static">
|
||||
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
|
||||
<span class="s-folder-name s-folder-name-static">Downloaded</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
|
||||
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if completedCat}
|
||||
<div class="s-folder-row s-folder-row-static">
|
||||
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
|
||||
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
|
||||
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
|
||||
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-folder-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
||||
{#each orderedCatIds.filter(id => id !== completedId) as id}
|
||||
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
|
||||
{@const hidden = isHidden(id)}
|
||||
{#if cat}
|
||||
{#if isBuiltin || cat}
|
||||
<div
|
||||
class="s-folder-row"
|
||||
class:dragging={dragId === cat.id}
|
||||
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
||||
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
||||
ondragover={(e) => onDragOver(e, cat.id)}
|
||||
ondrop={(e) => onDrop(e, cat.id)}
|
||||
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
||||
class:dragging={dragStrId === id}
|
||||
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === "above"}
|
||||
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === "below"}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, id)}
|
||||
ondragover={(e) => onDragOver(e, id)}
|
||||
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }}
|
||||
ondrop={(e) => onDrop(e, id)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
{#if editingId === cat.id}
|
||||
<input class="s-input full" bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||
onblur={commitEdit} use:focusInput />
|
||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<div class="s-folder-identity" draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, cat.id)}
|
||||
ondragend={onDragEnd}>
|
||||
<span class="s-folder-icon">
|
||||
<FolderSimple size={14} weight="light" />
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
||||
</div>
|
||||
|
||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
{#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:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||
</button>
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
||||
<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" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
||||
</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
<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}
|
||||
<input class="s-input full" bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||
onblur={commitEdit} use:focusInput />
|
||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<div class="s-folder-identity" draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, id)}
|
||||
ondragend={onDragEnd}>
|
||||
<span class="s-folder-icon">
|
||||
<FolderSimple size={14} weight="light" />
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
||||
</div>
|
||||
|
||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||
</button>
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
||||
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
||||
</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -314,28 +326,24 @@
|
||||
.s-folder-row.drop-above::before { top: -1px; }
|
||||
.s-folder-row.drop-below::after { bottom: -1px; }
|
||||
|
||||
.s-folder-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.s-folder-row-static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.s-folder-icon-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
@@ -371,14 +379,6 @@
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.s-folder-name-static {
|
||||
cursor: default;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.s-folder-name-static:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.s-folder-actions {
|
||||
display: flex;
|
||||
@@ -400,12 +400,6 @@
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.s-folder-divider {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.s-btn-icon.active {
|
||||
color: var(--accent, #6c8ef5);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { selectPortal } from "@core/actions/selectPortal";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
@@ -12,6 +13,12 @@
|
||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props();
|
||||
|
||||
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>
|
||||
|
||||
<div class="s-panel">
|
||||
@@ -43,14 +50,70 @@
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Server</p>
|
||||
<div class="s-section-body">
|
||||
|
||||
<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>
|
||||
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
||||
<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="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">
|
||||
<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 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>
|
||||
|
||||
@@ -76,6 +139,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Window</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<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">
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Integrations</p>
|
||||
<div class="s-section-body">
|
||||
@@ -112,4 +189,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.s-seg { display: flex; border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.s-seg-btn { flex: 1; padding: var(--sp-1) var(--sp-3); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); background: transparent; cursor: pointer; transition: background var(--t-base), color var(--t-base); border: none; }
|
||||
.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: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>
|
||||
@@ -69,6 +69,10 @@
|
||||
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries and notify you of new matches</span></div>
|
||||
<button role="switch" aria-checked={store.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={store.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(store.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Disable auto-complete</span><span class="s-desc">Don't move manga to the Completed folder when all chapters are read</span></div>
|
||||
<button role="switch" aria-checked={store.settings.disableAutoComplete} aria-label="Disable auto-complete" class="s-toggle" class:on={store.settings.disableAutoComplete} onclick={() => updateSettings({ disableAutoComplete: !store.settings.disableAutoComplete })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
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 { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
|
||||
|
||||
@@ -33,72 +33,85 @@
|
||||
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
|
||||
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) {
|
||||
secSaved = key; secError = null;
|
||||
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!secLoaded) { secLoaded = true; loadServerSecurity(); }
|
||||
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity(); }
|
||||
});
|
||||
|
||||
async function loadServerSecurity() {
|
||||
try {
|
||||
const res = await gql<{ settings: {
|
||||
authMode: string; authUsername: string;
|
||||
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
||||
socksProxyVersion: number; socksProxyUsername: string;
|
||||
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
||||
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
}}>(GET_SERVER_SECURITY);
|
||||
const s = res.settings;
|
||||
authMode = store.settings.serverAuthMode ?? "NONE";
|
||||
authUsername = s.authUsername || store.settings.serverAuthUser || "";
|
||||
updateSettings({ serverAuthUser: authUsername });
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||
socksUsername = s.socksProxyUsername;
|
||||
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
|
||||
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
|
||||
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
||||
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const res = await gql<{ settings: {
|
||||
authMode: string; authUsername: string;
|
||||
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
||||
socksProxyVersion: number; socksProxyUsername: string;
|
||||
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
||||
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
}}>(GET_SERVER_SECURITY);
|
||||
const s = res.settings;
|
||||
const serverMode = normalizeAuthMode(s.authMode);
|
||||
if (serverMode !== "UI_LOGIN") authSession.clearTokens();
|
||||
authMode = serverMode;
|
||||
authUsername = s.authUsername || "";
|
||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername });
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||
socksUsername = s.socksProxyUsername;
|
||||
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
|
||||
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
|
||||
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
||||
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
secLoading = true; secError = null;
|
||||
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
||||
|
||||
try {
|
||||
const newUser = authMode !== "NONE" ? authUsername.trim() : "";
|
||||
const newPass = authMode !== "NONE" ? authPassword.trim() : "";
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
|
||||
const newUser = authUsername.trim();
|
||||
const newPass = authPassword.trim();
|
||||
authSession.clearTokens();
|
||||
if (authMode === "UI_LOGIN") {
|
||||
authSession.clearTokens();
|
||||
await loginUI(newUser, newPass);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
|
||||
} else if (authMode === "BASIC_AUTH") {
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
} else {
|
||||
authSession.clearTokens();
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
}
|
||||
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
|
||||
authPassword = "";
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
secError = e?.message ?? "Failed to save authentication settings";
|
||||
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 });
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -223,7 +236,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<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"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,18 @@
|
||||
import type { BackupEntry } from "@core/persistence/persist";
|
||||
import { DEFAULT_SETTINGS } from "@types/settings";
|
||||
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";
|
||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
||||
|
||||
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: "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: "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: "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: "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: "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 },
|
||||
]);
|
||||
|
||||
let confirming = $state<string | null>(null);
|
||||
@@ -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) {
|
||||
confirming = null;
|
||||
patchReset(key, { state: "busy", error: null });
|
||||
try {
|
||||
switch (key) {
|
||||
case "moku-cache":
|
||||
await invoke("clear_moku_cache");
|
||||
break;
|
||||
case "suwayomi-cache":
|
||||
await invoke("clear_suwayomi_cache");
|
||||
break;
|
||||
case "server-cache":
|
||||
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
|
||||
case "all-cache":
|
||||
await clearAllCaches();
|
||||
break;
|
||||
case "reading-history":
|
||||
store.clearHistory();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
const { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const os = platform();
|
||||
const isMac = os === "macos";
|
||||
@@ -31,7 +33,7 @@
|
||||
<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>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
const leaving = new Set<string>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
let detail = $state<Toast | null>(null);
|
||||
|
||||
function schedule(t: Toast) {
|
||||
if (timers.has(t.id)) return;
|
||||
const dur = t.duration ?? 3500;
|
||||
@@ -30,12 +32,23 @@
|
||||
dismissToast(id);
|
||||
}
|
||||
|
||||
function openDetail(e: MouseEvent, t: Toast) {
|
||||
e.preventDefault();
|
||||
detail = t;
|
||||
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id); }
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detail = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||
store.toasts.forEach(schedule);
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||
}
|
||||
if (detail && !activeIds.has(detail.id)) detail = null;
|
||||
});
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
@@ -49,7 +62,10 @@
|
||||
{#if store.toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each store.toasts as t (t.id)}
|
||||
<button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}" onclick={() => dismiss(t.id)}>
|
||||
<button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}"
|
||||
onclick={() => dismiss(t.id)}
|
||||
oncontextmenu={(e) => openDetail(e, t)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -65,6 +81,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail}
|
||||
<div class="detail-backdrop" role="presentation" onclick={closeDetail} oncontextmenu={(e) => e.preventDefault()}>
|
||||
<div class="detail-panel detail-{detail.kind}" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="detail-accent"></div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-header">
|
||||
<span class="detail-kind">{detail.kind}</span>
|
||||
<button class="detail-close" onclick={closeDetail} aria-label="Close">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="detail-title">{detail.title}</p>
|
||||
{#if detail.body}
|
||||
<pre class="detail-text">{detail.body}</pre>
|
||||
{/if}
|
||||
<div class="detail-actions">
|
||||
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.title}${detail!.body ? '\n' + detail!.body : ''}`)}>
|
||||
Copy
|
||||
</button>
|
||||
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); closeDetail(); }}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; }
|
||||
|
||||
@@ -105,4 +151,79 @@
|
||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||
.title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.detail-backdrop {
|
||||
position: fixed; inset: 0; z-index: 10000;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.15s ease both;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
.detail-panel {
|
||||
display: flex; width: 420px; max-width: calc(100vw - 32px); max-height: 60vh;
|
||||
border-radius: var(--radius-lg); background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset;
|
||||
overflow: hidden;
|
||||
animation: popIn 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes popIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }
|
||||
|
||||
.detail-accent { width: 3px; flex-shrink: 0; }
|
||||
.detail-error .detail-accent { background: var(--color-error); }
|
||||
.detail-success .detail-accent { background: var(--accent-fg); }
|
||||
.detail-info .detail-accent { background: var(--text-faint); }
|
||||
.detail-download .detail-accent { background: var(--accent-fg); }
|
||||
|
||||
.detail-body { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: var(--sp-3); gap: var(--sp-2); overflow: hidden; }
|
||||
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.detail-kind {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; color: var(--text-faint);
|
||||
}
|
||||
.detail-error .detail-kind { color: var(--color-error); }
|
||||
|
||||
.detail-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||||
background: none; border: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.detail-close:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.detail-title {
|
||||
font-family: var(--font-ui); font-size: var(--text-sm);
|
||||
color: var(--text-secondary); font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-snug); word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
font-family: var(--font-mono, monospace); font-size: var(--text-xs);
|
||||
color: var(--text-muted); line-height: var(--leading-relaxed);
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
background: var(--bg-void); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3);
|
||||
scrollbar-width: thin;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||
.detail-copy, .detail-dismiss {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px var(--sp-3); border-radius: var(--radius-sm); cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.detail-copy {
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
|
||||
}
|
||||
.detail-copy:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.detail-dismiss {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.detail-dismiss:hover { background: color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||
</style>
|
||||
+66
-43
@@ -4,7 +4,8 @@ import { trackingState } from "@features/tracking/store/tracki
|
||||
import { loadAllStores } from "@core/persistence/persist";
|
||||
import { notifyReauthSuccess } from "@api/client";
|
||||
|
||||
const MAX_ATTEMPTS = 40;
|
||||
const MAX_ATTEMPTS = 15;
|
||||
const BG_MAX_ATTEMPTS = 60;
|
||||
|
||||
export const boot = $state({
|
||||
serverProbeOk: false,
|
||||
@@ -26,6 +27,44 @@ export async function initStore() {
|
||||
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() {
|
||||
const gen = ++probeGeneration;
|
||||
boot.failed = false;
|
||||
@@ -36,51 +75,36 @@ export function startProbe() {
|
||||
async function probe() {
|
||||
if (gen !== probeGeneration) return;
|
||||
tries++;
|
||||
|
||||
const result = await probeServer();
|
||||
if (gen !== probeGeneration) return;
|
||||
|
||||
if (result === "ok") {
|
||||
boot.serverProbeOk = true;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (result === "ok") { handleProbeSuccess(gen); return; }
|
||||
if (result === "auth_required") { handleAuthRequired(gen); return; }
|
||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; startBackgroundProbe(gen); return; }
|
||||
|
||||
if (result === "auth_required") {
|
||||
boot.serverProbeOk = true;
|
||||
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) {
|
||||
try {
|
||||
await loginBasic(user, pass);
|
||||
if (gen !== probeGeneration) return;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
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(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
||||
setTimeout(probe, Math.min(750 + tries * 250, 3000));
|
||||
setTimeout(probe, Math.min(300 + tries * 150, 1500));
|
||||
}
|
||||
|
||||
setTimeout(probe, 2000);
|
||||
setTimeout(probe, 100);
|
||||
}
|
||||
|
||||
function startBackgroundProbe(gen: number) {
|
||||
let bgTries = 0;
|
||||
|
||||
async function bgProbe() {
|
||||
if (gen !== probeGeneration) return;
|
||||
bgTries++;
|
||||
const result = await probeServer();
|
||||
if (gen !== probeGeneration) return;
|
||||
|
||||
if (result === "ok") { handleProbeSuccess(gen); return; }
|
||||
if (result === "auth_required") { handleAuthRequired(gen); return; }
|
||||
if (bgTries >= BG_MAX_ATTEMPTS) return;
|
||||
|
||||
setTimeout(bgProbe, 2000);
|
||||
}
|
||||
|
||||
setTimeout(bgProbe, 2000);
|
||||
}
|
||||
|
||||
export function stopProbe() {
|
||||
@@ -94,7 +118,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
|
||||
}
|
||||
boot.loginBusy = true;
|
||||
boot.loginError = null;
|
||||
|
||||
try {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
@@ -102,7 +125,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
|
||||
} else {
|
||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
|
||||
}
|
||||
|
||||
boot.loginRequired = false;
|
||||
boot.sessionExpired = false;
|
||||
boot.skipped = false;
|
||||
@@ -128,10 +150,11 @@ export function retryBoot() {
|
||||
}
|
||||
|
||||
export function bypassBoot(onReady: () => void) {
|
||||
probeGeneration++;
|
||||
const gen = probeGeneration;
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
boot.sessionExpired = false;
|
||||
boot.skipped = true;
|
||||
onReady();
|
||||
startBackgroundProbe(gen);
|
||||
}
|
||||
@@ -292,6 +292,7 @@ class Store {
|
||||
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||
UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA?: string, mangaStatus?: string,
|
||||
): Promise<void> {
|
||||
if (this.settings.disableAutoComplete) return;
|
||||
if (!chaps.length || mangaStatus === "ONGOING") return;
|
||||
const completed = categories.find(c => c.name === "Completed");
|
||||
if (!completed) return;
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface Settings {
|
||||
discordRpc: boolean;
|
||||
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
||||
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;
|
||||
idleTimeoutMin?: number; splashCards?: boolean;
|
||||
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
|
||||
@@ -128,6 +128,9 @@ export interface Settings {
|
||||
downloadAutoRetry: boolean;
|
||||
hiddenLibraryTabs: string[];
|
||||
libraryPinnedTabOrder: string[];
|
||||
autoScroll?: boolean;
|
||||
autoScrollSpeed?: number;
|
||||
disableAutoComplete: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -140,7 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
discordRpc: false,
|
||||
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
||||
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,
|
||||
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
||||
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
|
||||
@@ -171,4 +174,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadAutoRetry: false,
|
||||
hiddenLibraryTabs: [],
|
||||
libraryPinnedTabOrder: [],
|
||||
autoScroll: false,
|
||||
autoScrollSpeed: 5,
|
||||
disableAutoComplete: false,
|
||||
};
|
||||
Reference in New Issue
Block a user