mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Compare commits
110 Commits
v0.9.1
..
bf071dcfc7
| Author | SHA1 | Date | |
|---|---|---|---|
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bee8117aac | |||
| 0bea9c22cb | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e | |||
| 83711c155d | |||
| 3702a25813 | |||
| a71cc719ba | |||
| 1801fecdbb | |||
| 0cd799f450 | |||
| 5dab7761bc | |||
| 552a11a517 | |||
| c8ec6d6b90 | |||
| daaeae00fe | |||
| 79cb2f7c56 | |||
| 4d3dfdbec6 | |||
| 78573eacb1 | |||
| 1bb7da3b22 | |||
| dd0cf9372d | |||
| 50928c6343 | |||
| 170493aa71 | |||
| c009bd71fc | |||
| 4df7f416a7 | |||
| 63209cb828 | |||
| 2c1391c378 | |||
| 41fd4a820c | |||
| 9f3c6d2ac3 | |||
| f5b3f76b5d | |||
| 528f966b1f | |||
| 75e8bc5986 | |||
| 8123053a40 |
@@ -88,10 +88,10 @@ jobs:
|
|||||||
- name: Download Suwayomi (Linux x64)
|
- name: Download Suwayomi (Linux x64)
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
|
||||||
-o suwayomi-linux.tar.gz
|
-o suwayomi-linux.tar.gz
|
||||||
|
|
||||||
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c -
|
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
|
||||||
|
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
download_suwayomi() {
|
download_suwayomi() {
|
||||||
local asset="$1" sha="$2" outdir="$3"
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||||
-o "${outdir}.tar.gz"
|
-o "${outdir}.tar.gz"
|
||||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
mkdir -p "${outdir}"
|
mkdir -p "${outdir}"
|
||||||
@@ -87,13 +87,13 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
download_suwayomi \
|
download_suwayomi \
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||||
"suwayomi-arm64"
|
"suwayomi-arm64"
|
||||||
|
|
||||||
download_suwayomi \
|
download_suwayomi \
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||||
"suwayomi-x64"
|
"suwayomi-x64"
|
||||||
|
|
||||||
- name: Stage Suwayomi sidecars
|
- name: Stage Suwayomi sidecars
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
- name: Extract Suwayomi bundle
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
# --- Build Artifacts ---
|
# --- Build Artifacts ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
suwayomi-raw/
|
||||||
|
suwayomi-windows.zip
|
||||||
|
suwayomi.zip
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
|
||||||
# --- Nix ---
|
# --- Nix ---
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
@@ -32,6 +36,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# --- Tauri specific ---
|
# --- Tauri specific ---
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.9.1
|
pkgver=0.9.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -13,27 +13,46 @@ depends=(
|
|||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
'rust'
|
'rust'
|
||||||
'cargo'
|
|
||||||
'nodejs'
|
|
||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
|
optdepends=(
|
||||||
|
'discord: Discord rich presence'
|
||||||
|
)
|
||||||
|
options=('!strip')
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
)
|
)
|
||||||
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
sha256sums=(
|
sha256sums=(
|
||||||
|
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||||
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
|
)
|
||||||
|
b2sums=(
|
||||||
|
'SKIP'
|
||||||
'SKIP'
|
'SKIP'
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
|
||||||
|
mkdir -p src-tauri/.cargo
|
||||||
|
cat > src-tauri/.cargo/config.toml << 'EOF'
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cflags="${fixed_cflags/-flto=auto/}"
|
||||||
|
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
|
||||||
|
CFLAGS="$fixed_cflags" \
|
||||||
|
CXXFLAGS="$fixed_cxxflags" \
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -45,11 +64,11 @@ package() {
|
|||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -60,11 +79,11 @@ server.autoDownloadNewChapters = false
|
|||||||
server.globalUpdateInterval = 12
|
server.globalUpdateInterval = 12
|
||||||
server.maxSourcesInParallel = 6
|
server.maxSourcesInParallel = 6
|
||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
CONF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
@@ -93,7 +112,7 @@ exec java \
|
|||||||
-Dsun.awt.disablegui=true \
|
-Dsun.awt.disablegui=true \
|
||||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
LAUNCHER
|
||||||
|
|
||||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
@@ -105,6 +124,6 @@ EOF
|
|||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
install -Dm644 LICENSE \
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/moku-project/Moku/releases/latest)
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
[](https://github.com/moku-project/Moku/commits/main)
|

|
||||||
[](https://github.com/moku-project/Moku)
|
[](https://github.com/moku-project/Moku)
|
||||||
[](https://discord.gg/x97hj8zR72)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ Major Revisions:
|
|||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
|
||||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
|
||||||
|
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Fix Library-Refresh System (TESTING)
|
- Fix Library-Refresh System (TESTING)
|
||||||
@@ -19,21 +16,24 @@ Priority Bugs:
|
|||||||
- Allow User to Wipe Suwayomi (Scratch)
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
- If Possible, Component based Wipe (Library, Etc)
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
|
Pending/On-Hold:
|
||||||
In-Progress:
|
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
- Working on 3D Display Cards
|
- Working on 3D Display Cards
|
||||||
- Add Flathub Support (Pending Video)
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
|
- Change Auto-Link Threshold
|
||||||
|
- Fix Auto-Link De-dupe for Images
|
||||||
|
- Optimize Auto-Link Latency (IP)
|
||||||
|
|
||||||
|
In-Progress:
|
||||||
- Fix Tracking Login
|
- Fix Tracking Login
|
||||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
- Tracking
|
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||||
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
- Note User's have to always install extensions manually
|
||||||
|
- Create "Missing Source" for Manga
|
||||||
- Hide Completed from Library Settting
|
|
||||||
|
|
||||||
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
|
|
||||||
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
Notes from last time:
|
Notes from last time:
|
||||||
- Currently working on #42, just need to mount panel and fix button in reader
|
|
||||||
@@ -14,11 +14,15 @@
|
|||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem =
|
||||||
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.9.1";
|
version = "0.9.4";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -26,7 +30,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [ "rust-src" "rust-analyzer" ];
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
@@ -48,8 +55,10 @@
|
|||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
frontendSrc = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter = path: type:
|
filter =
|
||||||
let base = builtins.baseNameOf path;
|
path: type:
|
||||||
|
let
|
||||||
|
base = builtins.baseNameOf path;
|
||||||
in
|
in
|
||||||
(lib.hasInfix "/src" path)
|
(lib.hasInfix "/src" path)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
@@ -59,268 +68,44 @@
|
|||||||
|| base == "vite.config.ts";
|
|| base == "vite.config.ts";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
cargoSrc = lib.cleanSourceWith {
|
||||||
src = ./src-tauri;
|
src = ./src-tauri;
|
||||||
filter = path: type:
|
filter =
|
||||||
|
path: type:
|
||||||
(craneLib.filterCargoSources path type)
|
(craneLib.filterCargoSources path type)
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|| (lib.hasInfix "/icons/" path)
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|| (lib.hasInfix "/capabilities/" path)
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||||
};
|
};
|
||||||
|
|
||||||
commonArgs = {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||||
src = cargoSrc;
|
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
inherit version;
|
||||||
strictDeps = true;
|
src = frontendSrc;
|
||||||
buildInputs = runtimeLibs;
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
|
||||||
preBuild = ''
|
|
||||||
cp -r ${frontend} ../dist
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
moku = import ./nix/moku.nix {
|
||||||
|
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
appIcon = ./src/assets/moku-icon.svg;
|
||||||
inherit cargoArtifacts;
|
|
||||||
meta.mainProgram = "moku";
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p "$out/share/applications"
|
|
||||||
cat > "$out/share/applications/moku.desktop" << EOF
|
|
||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Moku
|
|
||||||
Comment=Manga reader frontend for Suwayomi
|
|
||||||
Exec=$out/bin/moku
|
|
||||||
Icon=moku
|
|
||||||
Terminal=false
|
|
||||||
Categories=Graphics;Viewer;
|
|
||||||
Keywords=manga;comic;reader;suwayomi;
|
|
||||||
StartupWMClass=moku
|
|
||||||
EOF
|
|
||||||
|
|
||||||
for size in 32x32 128x128 256x256 512x512; do
|
|
||||||
src="icons/$size.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
for size in 128x128 256x256; do
|
|
||||||
src="icons/''${size}@2x.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
|
||||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
|
||||||
|
|
||||||
wrapProgram $out/bin/moku \
|
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
|
||||||
pkgs.gsettings-desktop-schemas
|
|
||||||
pkgs.gtk3
|
|
||||||
]}" \
|
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
|
||||||
--set GDK_BACKEND wayland \
|
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
|
|
||||||
nodejs_22 pnpm
|
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
|
|
||||||
echo "── Bumping version fields to $VERSION ──"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Regenerating Cargo.lock ──"
|
|
||||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
|
||||||
cd "$REPO"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
|
||||||
echo "sha256: $FRONTEND_SHA"
|
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Patching flatpak manifest (version + frontend sha256) ──"
|
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
|
||||||
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
|
||||||
import re, sys
|
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
|
||||||
text = open(path).read()
|
|
||||||
updated, n = re.subn(
|
|
||||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
|
||||||
r'\g<1>' + sha, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Bumped to v$VERSION"
|
|
||||||
echo ""
|
|
||||||
echo "Commit field in the flatpak manifest still points to the old tag."
|
|
||||||
echo "After pushing the tag, run:"
|
|
||||||
echo " nix run .#post-tag-bump -- $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
postTagBumpScript = pkgs.writeShellApplication {
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||||
name = "moku-post-tag-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
|
||||||
|
|
||||||
echo "── Resolving commit for v$VERSION ──"
|
|
||||||
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
|
||||||
| awk '{print $1}')
|
|
||||||
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
|
||||||
echo "commit: $COMMIT"
|
|
||||||
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Fetching PKGBUILD tarball sha256 ──"
|
|
||||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
|
||||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "post-tag-bump complete for v$VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
flatpakScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-flatpak";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
gnused coreutils git
|
|
||||||
appstream flatpak-builder flatpak
|
|
||||||
];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
|
||||||
|
|
||||||
echo "── Building flatpak for v$VERSION ──"
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="$REPO/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"$REPO/build-dir" \
|
|
||||||
"$MANIFEST"
|
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "moku.flatpak created — v$VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-pkgbuild-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
|
||||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
|
||||||
|
|
||||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
|
||||||
echo "Fetching tarball sha256..."
|
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
|
||||||
|
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
|
||||||
|
|
||||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
tunnelScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-tunnel";
|
|
||||||
runtimeInputs = with pkgs; [ cloudflared ];
|
|
||||||
text = ''
|
|
||||||
PORT="''${1:-4567}"
|
|
||||||
cloudflared tunnel --url "http://localhost:$PORT"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
packages = {
|
||||||
|
inherit moku frontend suwayomiServer;
|
||||||
|
default = moku;
|
||||||
|
};
|
||||||
|
|
||||||
apps = {
|
apps = {
|
||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
|
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
|
||||||
};
|
|
||||||
|
|
||||||
packages = {
|
|
||||||
inherit moku frontend;
|
|
||||||
default = moku;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -331,24 +116,27 @@ EOF
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomiServer
|
||||||
cloudflared
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
(python3.withPackages (ps: [
|
||||||
|
ps.aiohttp
|
||||||
|
ps.tomlkit
|
||||||
|
]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
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 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 "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release workflow:"
|
echo " nix run .#bump -- <ver>"
|
||||||
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
|
|
||||||
echo " git commit && git tag && git push"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
|
echo " nix run .#post-tag-bump -- <ver>"
|
||||||
echo " nix run .#flatpak -- <ver> build moku.flatpak"
|
echo " nix run .#flatpak -- <ver>"
|
||||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
echo " nix run .#tunnel -- [port]"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,77 @@ build-options:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
|
- name: intltool
|
||||||
|
buildsystem: autotools
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||||
|
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||||
|
|
||||||
|
- name: libdbusmenu
|
||||||
|
buildsystem: autotools
|
||||||
|
build-options:
|
||||||
|
cflags: -Wno-error
|
||||||
|
env:
|
||||||
|
HAVE_VALGRIND_FALSE: '#'
|
||||||
|
HAVE_VALGRIND_TRUE: ''
|
||||||
|
config-opts:
|
||||||
|
- --with-gtk=3
|
||||||
|
- --disable-static
|
||||||
|
- --disable-dumper
|
||||||
|
- --disable-tests
|
||||||
|
- --disable-gtk-doc
|
||||||
|
- --disable-vala
|
||||||
|
- --disable-introspection
|
||||||
|
cleanup:
|
||||||
|
- /include
|
||||||
|
- /libexec
|
||||||
|
- /lib/pkgconfig
|
||||||
|
- /lib/*.la
|
||||||
|
- /share/doc
|
||||||
|
- /share/libdbusmenu
|
||||||
|
- /share/gtk-doc
|
||||||
|
- /share/gir-1.0
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||||
|
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||||
|
|
||||||
|
- name: libayatana-ido
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||||
|
tag: 0.10.3
|
||||||
|
|
||||||
|
- name: libayatana-indicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||||
|
tag: 0.9.4
|
||||||
|
|
||||||
|
- name: libayatana-appindicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
|
- -DENABLE_BINDINGS_VALA=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||||
|
tag: 0.5.93
|
||||||
|
- type: shell
|
||||||
|
commands:
|
||||||
|
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
|
||||||
|
|
||||||
- name: openjdk
|
- name: openjdk
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -52,9 +123,6 @@ modules:
|
|||||||
- type: inline
|
- type: inline
|
||||||
dest-filename: catch_abort.c
|
dest-filename: catch_abort.c
|
||||||
contents: |
|
contents: |
|
||||||
// Linux only:
|
|
||||||
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
|
|
||||||
|
|
||||||
#define _GNU_SOURCE
|
#define _GNU_SOURCE
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
@@ -95,12 +163,12 @@ modules:
|
|||||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -114,23 +182,20 @@ modules:
|
|||||||
cat > /app/bin/tachidesk-server << 'EOF'
|
cat > /app/bin/tachidesk-server << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# Seed conf on first run
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
@@ -155,8 +220,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
||||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -166,10 +231,10 @@ modules:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
@@ -179,11 +244,11 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/moku-project/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.9.1
|
tag: v0.9.4
|
||||||
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
|
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
|
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
|
||||||
|
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
||||||
|
|
||||||
|
pnpmDeps = fetchPnpmDeps {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
fetcherVersion = 1;
|
||||||
|
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = "pnpm build";
|
||||||
|
installPhase = "cp -r dist $out";
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
craneLib,
|
||||||
|
pkgs,
|
||||||
|
runtimeLibs,
|
||||||
|
frontend,
|
||||||
|
suwayomiServer,
|
||||||
|
version,
|
||||||
|
cargoSrc,
|
||||||
|
appIcon,
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
commonArgs = {
|
||||||
|
src = cargoSrc;
|
||||||
|
pname = "moku";
|
||||||
|
inherit version;
|
||||||
|
strictDeps = true;
|
||||||
|
buildInputs = runtimeLibs;
|
||||||
|
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||||
|
preBuild = ''
|
||||||
|
cp -r ${frontend} ../dist
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
in
|
||||||
|
craneLib.buildPackage (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
|
||||||
|
meta.mainProgram = "moku";
|
||||||
|
|
||||||
|
postInstall = ''
|
||||||
|
mkdir -p "$out/share/applications"
|
||||||
|
cat > "$out/share/applications/moku.desktop" << EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Moku
|
||||||
|
Comment=Manga reader frontend for Suwayomi
|
||||||
|
Exec=$out/bin/moku
|
||||||
|
Icon=moku
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Viewer;
|
||||||
|
Keywords=manga;comic;reader;suwayomi;
|
||||||
|
StartupWMClass=moku
|
||||||
|
EOF
|
||||||
|
|
||||||
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
|
src="icons/$size.png"
|
||||||
|
[ -f "$src" ] && install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
for size in 128x128 256x256; do
|
||||||
|
src="icons/''${size}@2x.png"
|
||||||
|
[ -f "$src" ] && install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
install -Dm644 "${appIcon}" \
|
||||||
|
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||||
|
|
||||||
|
wrapProgram $out/bin/moku \
|
||||||
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
|
pkgs.gsettings-desktop-schemas
|
||||||
|
pkgs.gtk3
|
||||||
|
]}" \
|
||||||
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
|
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
|
||||||
|
--set GDK_BACKEND wayland \
|
||||||
|
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
|
'';
|
||||||
|
})
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
{ pkgs, rustToolchain, version }:
|
||||||
|
|
||||||
|
{
|
||||||
|
bump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
echo "── Bumping version fields to $VERSION ──"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating Cargo.lock ──"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building frontend ──"
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Patching flatpak manifest ──"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
postTagBump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-post-tag-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
echo "── Resolving commit for v$VERSION ──"
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
||||||
|
| awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
echo "commit: $COMMIT"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "post-tag-bump complete for v$VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpak = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$MANIFEST"
|
||||||
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
|
||||||
|
echo "moku.flatpak created"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
tunnel = pkgs.writeShellApplication {
|
||||||
|
name = "moku-tunnel";
|
||||||
|
runtimeInputs = with pkgs; [ cloudflared ];
|
||||||
|
text = ''
|
||||||
|
PORT="''${1:-4567}"
|
||||||
|
cloudflared tunnel --url "http://localhost:$PORT"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
fetchurl,
|
||||||
|
makeWrapper,
|
||||||
|
jdk21_headless,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
jdk = jdk21_headless;
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
pname = "suwayomi-server";
|
||||||
|
version = "2.1.2087";
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
|
||||||
|
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
dontUnpack = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
|
||||||
|
|
||||||
|
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
|
||||||
|
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
|
||||||
|
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
||||||
|
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
||||||
|
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
||||||
|
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${finalAttrs.version}";
|
||||||
|
license = lib.licenses.mpl20;
|
||||||
|
platforms = jdk.meta.platforms;
|
||||||
|
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||||
|
mainProgram = "suwayomi-server";
|
||||||
|
};
|
||||||
|
})
|
||||||
+10
-8
@@ -10,22 +10,24 @@
|
|||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-http": "^2.5.8",
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@tauri-apps/plugin-store": "~2.4.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^4.0.1",
|
"svelte-spa-router": "^5.1.0",
|
||||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
"tauri-plugin-drpc": "^1.0.3"
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.11.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^3.0.0",
|
"svelte-check": "^4.4.7",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^5.0.0"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+385
-762
File diff suppressed because it is too large
Load Diff
Generated
+486
-889
File diff suppressed because it is too large
Load Diff
Generated
+290
-580
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.9.1"
|
version = "0.9.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,12 +15,13 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -31,6 +32,12 @@ urlencoding = "2"
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
reqwest = { version = "0.12", features = ["blocking"] }
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Security_Credentials_UI",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Moku — Suwayomi launcher for Linux AppImage/deb.
|
# — Suwayomi launcher for Linux AppImage/deb.
|
||||||
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||||
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||||
set -e
|
set -e
|
||||||
@@ -53,7 +53,7 @@ if [ ! -f "$JAR" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Data directory ─────────────────────────────────────────────────────────────
|
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||||
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
|
|||||||
cat > "$DATA_DIR/server.conf" << 'EOF'
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -79,13 +79,13 @@ fi
|
|||||||
|
|
||||||
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent (e.g. user-managed conf missing them)
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Moku",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:app:allow-default-window-icon",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
@@ -27,9 +33,11 @@
|
|||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor",
|
"core:window:allow-scale-factor",
|
||||||
"process:default",
|
"process:default",
|
||||||
|
"process:allow-exit",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
"http:allow-fetch",
|
"http:allow-fetch",
|
||||||
|
"store:default",
|
||||||
"discord-rpc:default",
|
"discord-rpc:default",
|
||||||
"discord-rpc:allow-connect",
|
"discord-rpc:allow-connect",
|
||||||
"discord-rpc:allow-disconnect",
|
"discord-rpc:allow-disconnect",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||||
|
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("auto-moku-backup-")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_local_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let content = std::fs::read_to_string(base.join(&name))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
(name, content)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_hello {
|
||||||
|
use windows::{
|
||||||
|
core::HSTRING,
|
||||||
|
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
|
||||||
|
Win32::UI::WindowsAndMessaging::{
|
||||||
|
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
std::ffi::OsStr::new(s)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_focus_hello_dialog() -> bool {
|
||||||
|
let cls = to_wide("Credential Dialog Xaml Host");
|
||||||
|
unsafe {
|
||||||
|
let Ok(hwnd) = FindWindowW(
|
||||||
|
windows::core::PCWSTR(cls.as_ptr()),
|
||||||
|
windows::core::PCWSTR::null(),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if IsIconic(hwnd).as_bool() {
|
||||||
|
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
}
|
||||||
|
let _ = BringWindowToTop(hwnd);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nudge_focus(retries: u32, delay_ms: u64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
for _ in 0..retries {
|
||||||
|
if try_focus_hello_dialog() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
|
let reason = reason.to_owned();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||||
|
.and_then(|op| {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
op.get()
|
||||||
|
});
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
UserConsentVerificationResult::Verified => Ok(()),
|
||||||
|
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
|
||||||
|
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
|
||||||
|
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
|
||||||
|
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
|
||||||
|
_ => Err("authenticationFailed".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_available() -> bool {
|
||||||
|
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||||
|
UserConsentVerifier::CheckAvailabilityAsync()
|
||||||
|
.and_then(|op| op.get())
|
||||||
|
.map(|a| a == UserConsentVerifierAvailability::Available)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::authenticate(&_reason);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
Err("notSupported".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_available() -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::is_available();
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod backup;
|
||||||
|
pub mod biometric;
|
||||||
|
pub mod server;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod system;
|
||||||
|
pub mod updater;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
|
||||||
|
use crate::ServerState;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
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() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
|
let mut log = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let binary_args = binary_args.unwrap_or_default();
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&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, web_ui_enabled);
|
||||||
|
|
||||||
|
let mut invocation =
|
||||||
|
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !binary_args.trim().is_empty() {
|
||||||
|
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
|
||||||
|
let mut merged = extra;
|
||||||
|
merged.extend(invocation.args);
|
||||||
|
invocation.args = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
|
let rootdir_flag = format!(
|
||||||
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
data_dir.to_string_lossy()
|
||||||
|
);
|
||||||
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let working_dir = invocation
|
||||||
|
.working_dir
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!(
|
||||||
|
"[spawn_server] bin={:?} args={:?} cwd={:?}",
|
||||||
|
invocation.bin, invocation.args, working_dir
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
let cmd = app
|
||||||
|
.shell()
|
||||||
|
.command(&invocation.bin)
|
||||||
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
|
.args(&invocation.args)
|
||||||
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
server::kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use sysinfo::Disks;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StorageInfo {
|
||||||
|
pub manga_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub free_bytes: u64,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
|
if !downloads_path.trim().is_empty() {
|
||||||
|
return PathBuf::from(downloads_path.trim());
|
||||||
|
}
|
||||||
|
suwayomi_data_dir().join("downloads")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||||
|
let path = resolve_downloads_path(&downloads_path);
|
||||||
|
|
||||||
|
let manga_bytes = if path.exists() {
|
||||||
|
WalkDir::new(&path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| e.metadata().ok())
|
||||||
|
.filter(|m| m.is_file())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let stat_path = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
let disk = disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
|
Ok(StorageInfo {
|
||||||
|
manga_bytes,
|
||||||
|
total_bytes: disk.total_space(),
|
||||||
|
free_bytes: disk.available_space(),
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_default_downloads_path() -> String {
|
||||||
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_path_exists(path: String) -> bool {
|
||||||
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn migrate_downloads(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
src: String,
|
||||||
|
dst: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = PathBuf::from(src.trim());
|
||||||
|
let dst_path = PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(&src_path)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use crate::server::resolve::strip_unc;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restart_app(app: tauri::AppHandle) {
|
||||||
|
tauri::process::restart(&app.env());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_path(path: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let p = strip_unc(PathBuf::from(path.trim()));
|
||||||
|
std::process::Command::new("explorer")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("xdg-open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Downloads Folder")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.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 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() {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for dir in &["cache/kcef", "logs"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
crate::server::kill_tachidesk(&app);
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
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}"))?;
|
||||||
|
} else if p.exists() {
|
||||||
|
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct ReleaseInfo {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
struct UpdateProgress {
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GhRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
published_at: Option<String>,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let releases: Vec<GhRelease> =
|
||||||
|
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ReleaseInfo {
|
||||||
|
tag_name: r.tag_name.clone(),
|
||||||
|
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||||
|
body: r.body.unwrap_or_default(),
|
||||||
|
published_at: r.published_at.unwrap_or_default(),
|
||||||
|
html_url: r.html_url,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!(
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
|
||||||
|
tag
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"GitHub API returned {} for tag {}",
|
||||||
|
resp.status(),
|
||||||
|
tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
|
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||||
|
|
||||||
|
let total = if asset.size > 0 {
|
||||||
|
Some(asset.size)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut resp = client
|
||||||
|
.get(&asset.browser_download_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||||
|
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
std::process::Command::new(&tmp_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = app.emit("update-launching", ());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-800
@@ -1,810 +1,84 @@
|
|||||||
use std::path::PathBuf;
|
mod commands;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::Write;
|
use std::io::{Read, Write};
|
||||||
use sysinfo::Disks;
|
use std::net::{TcpListener, TcpStream};
|
||||||
use serde::Serialize;
|
use tauri::{
|
||||||
use tauri::{Manager, WindowEvent};
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
#[cfg(target_os = "windows")]
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
use tauri::Emitter;
|
Manager, WindowEvent,
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
struct ServerState(Mutex<Option<CommandChild>>);
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct StorageInfo {
|
|
||||||
manga_bytes: u64,
|
|
||||||
total_bytes: u64,
|
|
||||||
free_bytes: u64,
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
#[serde(tag = "kind", content = "message")]
|
|
||||||
pub enum SpawnError {
|
|
||||||
NotConfigured(String),
|
|
||||||
SpawnFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
|
||||||
pub struct ReleaseInfo {
|
|
||||||
pub tag_name: String,
|
|
||||||
pub name: String,
|
|
||||||
pub body: String,
|
|
||||||
pub published_at: String,
|
|
||||||
pub html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
struct UpdateProgress {
|
|
||||||
downloaded: u64,
|
|
||||||
total: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
|
||||||
let s = path.to_string_lossy();
|
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
||||||
PathBuf::from(stripped)
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|
||||||
if !downloads_path.trim().is_empty() {
|
|
||||||
return PathBuf::from(downloads_path.trim());
|
|
||||||
}
|
|
||||||
suwayomi_data_dir().join("downloads")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|
||||||
let path = resolve_downloads_path(&downloads_path);
|
|
||||||
|
|
||||||
let manga_bytes = if path.exists() {
|
|
||||||
WalkDir::new(&path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter_map(|e| e.metadata().ok())
|
|
||||||
.filter(|m| m.is_file())
|
|
||||||
.map(|m| m.len())
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
};
|
||||||
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
|
||||||
let stat_path = if path.exists() {
|
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let disks = Disks::new_with_refreshed_list();
|
const IPC_PORT: u16 = 47823;
|
||||||
let disk = disks
|
const HANDSHAKE: &[u8] = b"MOKU:1\n";
|
||||||
.iter()
|
const FOCUS_CMD: &[u8] = b"focus\n";
|
||||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
|
||||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
|
||||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
|
||||||
|
|
||||||
Ok(StorageInfo {
|
fn do_quit(app: &tauri::AppHandle) {
|
||||||
manga_bytes,
|
server::kill_tachidesk(app);
|
||||||
total_bytes: disk.total_space(),
|
app.exit(0);
|
||||||
free_bytes: disk.available_space(),
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
fn start_instance_listener(app: tauri::AppHandle) {
|
||||||
fn get_default_downloads_path() -> String {
|
std::thread::spawn(move || {
|
||||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn check_path_exists(path: String) -> bool {
|
|
||||||
std::path::Path::new(path.trim()).is_dir()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn create_directory(path: String) -> Result<(), String> {
|
|
||||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let src_path = std::path::PathBuf::from(src.trim());
|
|
||||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
|
||||||
|
|
||||||
if !src_path.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let total: u64 = WalkDir::new(&src_path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_type().is_file())
|
|
||||||
.count() as u64;
|
|
||||||
|
|
||||||
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
|
||||||
|
|
||||||
let mut done: u64 = 0;
|
|
||||||
|
|
||||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
|
||||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
|
||||||
let target = dst_path.join(rel);
|
|
||||||
|
|
||||||
if entry.file_type().is_dir() {
|
|
||||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = target.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
|
||||||
done += 1;
|
|
||||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
|
||||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
|
||||||
window.scale_factor().unwrap_or(1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if let Some(child) = state.0.lock().unwrap().take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let _ = std::process::Command::new("taskkill")
|
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.status();
|
|
||||||
|
|
||||||
for _ in 0..30 {
|
|
||||||
let still_running = std::process::Command::new("tasklist")
|
|
||||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !still_running { break; }
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
|
||||||
server.port = 4567
|
|
||||||
server.webUIEnabled = false
|
|
||||||
server.initialOpenInBrowserEnabled = false
|
|
||||||
server.systemTrayEnabled = false
|
|
||||||
server.webUIInterface = "browser"
|
|
||||||
server.webUIFlavor = "WebUI"
|
|
||||||
server.webUIChannel = "stable"
|
|
||||||
server.electronPath = ""
|
|
||||||
server.debugLogsEnabled = false
|
|
||||||
server.downloadAsCbz = true
|
|
||||||
server.autoDownloadNewChapters = false
|
|
||||||
server.globalUpdateInterval = 12
|
|
||||||
server.maxSourcesInParallel = 6
|
|
||||||
server.extensionRepos = []
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn seed_server_conf(data_dir: &PathBuf) {
|
|
||||||
let conf_path = data_dir.join("server.conf");
|
|
||||||
|
|
||||||
if !conf_path.exists() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
|
||||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
|
||||||
return;
|
return;
|
||||||
|
};
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
handle_ipc_connection(stream, &app);
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
});
|
||||||
eprintln!("Could not write server.conf: {e}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
let cmd = &msg[HANDSHAKE.len()..];
|
||||||
|
if cmd.starts_with(b"focus") {
|
||||||
let patched = patch_conf_key(
|
let _ = stream.write_all(b"ok\n");
|
||||||
patch_conf_key(
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
let _ = win.show();
|
||||||
"server.initialOpenInBrowserEnabled", "false",
|
let _ = win.unminimize();
|
||||||
),
|
let _ = win.set_focus();
|
||||||
"server.systemTrayEnabled", "false",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::write(&conf_path, patched);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
|
||||||
let replacement = format!("{key} = {value}");
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
|
|
||||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
|
||||||
let mut out = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
out.push('\n');
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = text;
|
|
||||||
if !out.ends_with('\n') { out.push('\n'); }
|
|
||||||
out.push_str(&replacement);
|
|
||||||
out.push('\n');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suwayomi_data_dir() -> PathBuf {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
|
||||||
.join("moku\\tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
|
||||||
.join("io.github.moku_project.Moku.app/tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
|
||||||
base.join("moku/tachidesk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ServerInvocation {
|
|
||||||
bin: String,
|
|
||||||
args: Vec<String>,
|
|
||||||
working_dir: Option<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");
|
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
|
||||||
if java.exists() { Some(java) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
|
||||||
eprintln!("{}", msg);
|
|
||||||
if let Some(f) = log {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
|
||||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
|
||||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
|
||||||
if path.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: path.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
do_log(log, "[resolve] user path not found, falling through");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
|
||||||
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()));
|
|
||||||
if p.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: Some(bin_dir.to_path_buf()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
fn signal_existing_instance() -> bool {
|
||||||
let resource_dir = {
|
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
|
||||||
let raw = app.path().resource_dir().unwrap_or_default();
|
return false;
|
||||||
let stripped = strip_unc(raw);
|
|
||||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
|
||||||
stripped
|
|
||||||
};
|
};
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
let mut msg = Vec::new();
|
||||||
{
|
msg.extend_from_slice(HANDSHAKE);
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
msg.extend_from_slice(FOCUS_CMD);
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
if stream.write_all(&msg).is_err() {
|
||||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
return false;
|
||||||
|
|
||||||
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".to_string(), jar.to_string_lossy().into_owned()],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
let mut resp = [0u8; 4];
|
||||||
{
|
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
|
||||||
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()));
|
|
||||||
if p.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: Some(resource_dir.clone()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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".to_string(), jar_path.to_string_lossy().into_owned()],
|
|
||||||
working_dir: Some(resource_dir),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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();
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
|
||||||
|
|
||||||
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 mut found_binary: Option<ServerInvocation> = None;
|
|
||||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
|
||||||
|
|
||||||
'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: vec![],
|
|
||||||
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 let Some(inv) = found_binary {
|
|
||||||
return Ok(inv);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((java, jar)) = found_java {
|
|
||||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
|
||||||
working_dir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
|
||||||
}
|
|
||||||
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
|
||||||
|
|
||||||
if found {
|
|
||||||
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(SpawnError::NotConfigured(
|
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
|
||||||
{
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if state.0.lock().unwrap().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
|
||||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
|
||||||
|
|
||||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
|
||||||
let rootdir_flag = format!(
|
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
|
||||||
data_dir.to_string_lossy()
|
|
||||||
);
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
|
||||||
}
|
|
||||||
|
|
||||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
|
||||||
.command(&invocation.bin)
|
|
||||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
|
||||||
.args(&invocation.args)
|
|
||||||
.current_dir(&working_dir);
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok((_rx, child)) => {
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
kill_tachidesk(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|
||||||
use tauri_plugin_http::reqwest;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Moku")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GitHub API returned {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct GhRelease {
|
|
||||||
tag_name: String,
|
|
||||||
name: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
published_at: Option<String>,
|
|
||||||
html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
|
||||||
tag_name: r.tag_name.clone(),
|
|
||||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
|
||||||
body: r.body.unwrap_or_default(),
|
|
||||||
published_at: r.published_at.unwrap_or_default(),
|
|
||||||
html_url: r.html_url,
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use tauri_plugin_http::reqwest;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Moku")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
|
|
||||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Asset { name: String, browser_download_url: String, size: u64 }
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Release { assets: Vec<Asset> }
|
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let asset = release.assets
|
|
||||||
.into_iter()
|
|
||||||
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
|
||||||
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
|
||||||
|
|
||||||
let total = if asset.size > 0 { Some(asset.size) } else { None };
|
|
||||||
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let tmp_path = std::env::temp_dir().join(&asset.name);
|
|
||||||
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
|
||||||
let mut downloaded: u64 = 0;
|
|
||||||
|
|
||||||
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
|
||||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
|
||||||
}
|
|
||||||
drop(file);
|
|
||||||
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
std::process::Command::new(&tmp_path)
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let _ = app.emit("update-launching", ());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn restart_app(app: tauri::AppHandle) {
|
|
||||||
tauri::process::restart(&app.env());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn open_path(path: String) -> Result<(), String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let p = strip_unc(std::path::PathBuf::from(path.trim()));
|
|
||||||
std::process::Command::new("explorer")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let p = std::path::Path::new(path.trim());
|
|
||||||
std::process::Command::new("open")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let p = std::path::Path::new(path.trim());
|
|
||||||
std::process::Command::new("xdg-open")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
app.dialog()
|
|
||||||
.file()
|
|
||||||
.set_title("Choose Downloads Folder")
|
|
||||||
.blocking_pick_folder()
|
|
||||||
.map(|p| p.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
|
||||||
app.path().app_data_dir()
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
|
||||||
.join("backups")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let filename = format!("moku-backup-{}.json", now);
|
|
||||||
|
|
||||||
let path = app.dialog()
|
|
||||||
.file()
|
|
||||||
.set_title("Save Moku app data backup")
|
|
||||||
.set_file_name(&filename)
|
|
||||||
.blocking_save_file()
|
|
||||||
.ok_or("Cancelled")?;
|
|
||||||
|
|
||||||
let dest = PathBuf::from(path.to_string());
|
|
||||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(dest.to_string_lossy().into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
|
|
||||||
let path = app.dialog()
|
|
||||||
.file()
|
|
||||||
.set_title("Open Moku app data backup")
|
|
||||||
.blocking_pick_file()
|
|
||||||
.ok_or("Cancelled")?;
|
|
||||||
|
|
||||||
let src = PathBuf::from(path.to_string());
|
|
||||||
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
|
||||||
let backup_dir = moku_backup_dir(&app);
|
|
||||||
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
|
|
||||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
entries.sort_by_key(|e| e.file_name());
|
|
||||||
|
|
||||||
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
|
||||||
let _ = std::fs::remove_file(old.path());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
|
||||||
moku_backup_dir(&app).to_string_lossy().into_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
if signal_existing_instance() {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_discord_rpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
@@ -813,30 +87,78 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
commands::storage::get_storage_info,
|
||||||
get_default_downloads_path,
|
commands::storage::get_default_downloads_path,
|
||||||
check_path_exists,
|
commands::storage::check_path_exists,
|
||||||
create_directory,
|
commands::storage::create_directory,
|
||||||
migrate_downloads,
|
commands::storage::migrate_downloads,
|
||||||
spawn_server,
|
commands::server::spawn_server,
|
||||||
kill_server,
|
commands::server::kill_server,
|
||||||
get_platform_ui_scale,
|
commands::system::get_platform_ui_scale,
|
||||||
list_releases,
|
commands::system::restart_app,
|
||||||
download_and_install_update,
|
commands::system::exit_app,
|
||||||
restart_app,
|
commands::system::clear_moku_cache,
|
||||||
open_path,
|
commands::system::clear_suwayomi_cache,
|
||||||
pick_downloads_folder,
|
commands::system::reset_suwayomi_data,
|
||||||
export_app_data,
|
commands::system::open_path,
|
||||||
import_app_data,
|
commands::system::pick_downloads_folder,
|
||||||
auto_backup_app_data,
|
commands::system::pick_server_binary,
|
||||||
get_auto_backup_dir,
|
commands::backup::export_app_data,
|
||||||
|
commands::backup::import_app_data,
|
||||||
|
commands::backup::auto_backup_app_data,
|
||||||
|
commands::backup::get_auto_backup_dir,
|
||||||
|
commands::backup::read_store_files,
|
||||||
|
commands::updater::list_releases,
|
||||||
|
commands::updater::download_and_install_update,
|
||||||
|
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| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
kill_tachidesk(window.app_handle());
|
server::kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "preview"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
if !conf_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||||
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
contents,
|
||||||
|
"server.webUIEnabled",
|
||||||
|
if web_ui_enabled { "true" } else { "false" },
|
||||||
|
),
|
||||||
|
"server.initialOpenInBrowserEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let mut out = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = text;
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&replacement);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pub mod conf;
|
||||||
|
pub mod resolve;
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::ServerState;
|
||||||
|
|
||||||
|
pub use resolve::SpawnError;
|
||||||
|
|
||||||
|
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(f) = log {
|
||||||
|
let _ = writeln!(f, "{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args(["-f", "tachidesk"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use crate::server::do_log;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
#[serde(tag = "kind", content = "message")]
|
||||||
|
pub enum SpawnError {
|
||||||
|
NotConfigured(String),
|
||||||
|
SpawnFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerInvocation {
|
||||||
|
pub bin: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||||
|
base.join("Tachidesk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||||
|
PathBuf::from(stripped)
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn java_bin_name() -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
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()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(bin_dir.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(jar_invocation(java, jar_path, resource_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||||
|
|
||||||
|
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||||
|
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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()));
|
||||||
|
|
||||||
|
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 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: launcher_sh.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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"] {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let resolved = std::process::Command::new("where")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.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)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.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()));
|
||||||
|
|
||||||
|
if let Some(bin_path) = resolved {
|
||||||
|
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: bin_path,
|
||||||
|
args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SpawnError::NotConfigured(
|
||||||
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.9.1",
|
"version": "0.9.4",
|
||||||
"identifier": "io.github.MokuProject.Moku",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+212
-13
@@ -4,9 +4,9 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
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 { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||||
import { applyTheme } from "@core/theme";
|
import { applyTheme } from "@core/theme";
|
||||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
let themeEditorOpen = $state(false);
|
let themeEditorOpen = $state(false);
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let closeDialogOpen = $state(false);
|
||||||
|
let closeRemember = $state(false);
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
function openThemeEditor(id?: string | null) {
|
||||||
themeEditorEditId = id ?? null;
|
themeEditorEditId = id ?? null;
|
||||||
themeEditorOpen = true;
|
themeEditorOpen = true;
|
||||||
@@ -41,6 +44,35 @@
|
|||||||
themeEditorEditId = null;
|
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.theme; applyTheme(); });
|
||||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||||
$effect(() => mountZoomKey());
|
$effect(() => mountZoomKey());
|
||||||
@@ -59,6 +91,13 @@
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
downloadStore.poll();
|
||||||
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
return () => clearInterval(dlInterval);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (store.settings.discordRpc) {
|
if (store.settings.discordRpc) {
|
||||||
initRpc();
|
initRpc();
|
||||||
@@ -93,31 +132,33 @@
|
|||||||
applyZoom();
|
applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||||
|
|
||||||
|
await initStore();
|
||||||
|
startProbe();
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
if (store.settings.autoStartServer) {
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
invoke<void>("spawn_server", {
|
||||||
|
binary: store.settings.serverBinary,
|
||||||
|
webUiEnabled: store.settings.suwayomiWebUI ?? false,
|
||||||
|
}).catch((err: any) => {
|
||||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||||
else console.warn("Could not start server:", err);
|
else console.warn("Could not start server:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
"download-progress",
|
"download-progress",
|
||||||
e => setActiveDownloads(e.payload),
|
e => setActiveDownloads(e.payload),
|
||||||
);
|
);
|
||||||
|
|
||||||
await downloadStore.poll();
|
|
||||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopProbe();
|
stopProbe();
|
||||||
clearInterval(dlInterval);
|
|
||||||
unlistenResize();
|
unlistenResize();
|
||||||
unlistenScale();
|
unlistenScale();
|
||||||
unlistenDownload();
|
unlistenDownload();
|
||||||
|
unlistenClose();
|
||||||
destroyRpc();
|
destroyRpc();
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
|
||||||
delete (window as any).__mokuShowSplash;
|
delete (window as any).__mokuShowSplash;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -127,7 +168,7 @@
|
|||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
|
|
||||||
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
{:else if !appReady && !boot.loginRequired}
|
||||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
@@ -135,7 +176,7 @@
|
|||||||
onRetry={retryBoot}
|
onRetry={retryBoot}
|
||||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||||
|
|
||||||
{:else if boot.unsupportedMode || boot.loginRequired}
|
{:else if boot.loginRequired}
|
||||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
<AuthGate onReady={() => { appReady = true; }} />
|
<AuthGate onReady={() => { appReady = true; }} />
|
||||||
|
|
||||||
@@ -145,8 +186,13 @@
|
|||||||
onDismiss={() => { idle = false; }} />
|
onDismiss={() => { idle = false; }} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if boot.sessionExpired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div id="app-shell" class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +205,160 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; 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>
|
</style>
|
||||||
+82
-6
@@ -1,9 +1,24 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { fetchAuthenticated } from "../core/auth";
|
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
|
||||||
|
import { boot } from "@store/boot.svelte";
|
||||||
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
type ReauthResolver = () => void;
|
||||||
|
let _reauthQueue: ReauthResolver[] = [];
|
||||||
|
|
||||||
|
export function notifyReauthSuccess() {
|
||||||
|
const queue = _reauthQueue;
|
||||||
|
_reauthQueue = [];
|
||||||
|
queue.forEach(resolve => resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReauth(): Promise<void> {
|
||||||
|
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerUrl(): string {
|
||||||
const url = store.settings.serverUrl;
|
const url = store.settings.serverUrl;
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
}
|
}
|
||||||
@@ -14,6 +29,14 @@ export function plainThumbUrl(path: string): string {
|
|||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveImageUrl(path: string): Promise<string> {
|
||||||
|
if (!path) return "";
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "NONE") return url;
|
||||||
|
return getBlobUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
export const thumbUrl = plainThumbUrl;
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
@@ -43,12 +66,13 @@ async function fetchWithRetry(
|
|||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
try {
|
try {
|
||||||
const res = await fetchAuthenticated(url, init, signal);
|
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.authRequired) throw e;
|
if (e?.authRequired) throw e;
|
||||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (e instanceof AuthRequiredError) throw e;
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
@@ -56,20 +80,72 @@ async function fetchWithRetry(
|
|||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchImage(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ src: string; revoke: () => void }> {
|
||||||
|
if (!path) return { src: "", revoke: () => {} };
|
||||||
|
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "NONE") return { src: url, revoke: () => {} };
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
||||||
|
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
return { src, revoke: () => URL.revokeObjectURL(src) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const 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(
|
const res = await fetchWithRetry(
|
||||||
`${getServerUrl()}/api/graphql`,
|
`${getServerUrl()}/api/graphql`,
|
||||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const retried = await tryRefreshAndRetry();
|
||||||
|
if (retried) return retried;
|
||||||
|
}
|
||||||
|
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
}
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
if (json.errors?.length) {
|
||||||
return json.data;
|
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 ?? "";
|
||||||
|
await waitForReauth();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
throw new Error(json.errors[0].message);
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return attempt();
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ export const FETCH_CHAPTERS = `
|
|||||||
fetchChapters(input: { mangaId: $mangaId }) {
|
fetchChapters(input: { mangaId: $mangaId }) {
|
||||||
chapters {
|
chapters {
|
||||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,3 +46,19 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SET_CHAPTER_META = `
|
||||||
|
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
|
||||||
|
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CHAPTER_META = `
|
||||||
|
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
|
||||||
|
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -17,6 +17,14 @@ export const UPDATE_EXTENSION = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTENSIONS = `
|
||||||
|
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extensions { apkName pkgName name isInstalled hasUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||||
mutation InstallExternalExtension($url: String!) {
|
mutation InstallExternalExtension($url: String!) {
|
||||||
installExternalExtension(input: { extensionUrl: $url }) {
|
installExternalExtension(input: { extensionUrl: $url }) {
|
||||||
@@ -25,6 +33,124 @@ export const INSTALL_EXTERNAL_EXTENSION = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_SOURCE_PREFERENCE = `
|
||||||
|
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
||||||
|
updateSourcePreference(input: { source: $source, change: $change }) {
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_META = `
|
||||||
|
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
||||||
|
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_CATEGORY_META = `
|
||||||
|
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
|
||||||
|
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY_META = `
|
||||||
|
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
|
||||||
|
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_GLOBAL_META = `
|
||||||
|
mutation SetGlobalMeta($key: String!, $value: String!) {
|
||||||
|
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_GLOBAL_META = `
|
||||||
|
mutation DeleteGlobalMeta($key: String!) {
|
||||||
|
deleteGlobalMeta(input: { key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_CACHED_IMAGES = `
|
||||||
|
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
||||||
|
clearCachedImages(input: {
|
||||||
|
cachedPages: $cachedPages
|
||||||
|
cachedThumbnails: $cachedThumbnails
|
||||||
|
downloadedThumbnails: $downloadedThumbnails
|
||||||
|
}) {
|
||||||
|
cachedPages cachedThumbnails downloadedThumbnails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESET_SETTINGS = `
|
||||||
|
mutation ResetSettings {
|
||||||
|
resetSettings(input: {}) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const SET_EXTENSION_REPOS = `
|
export const SET_EXTENSION_REPOS = `
|
||||||
mutation SetExtensionRepos($repos: [String!]!) {
|
mutation SetExtensionRepos($repos: [String!]!) {
|
||||||
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export const UPDATE_MANGA_CATEGORIES = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS_CATEGORIES = `
|
||||||
|
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
mangas { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const CREATE_CATEGORY = `
|
export const CREATE_CATEGORY = `
|
||||||
mutation CreateCategory($name: String!) {
|
mutation CreateCategory($name: String!) {
|
||||||
createCategory(input: { name: $name }) {
|
createCategory(input: { name: $name }) {
|
||||||
@@ -49,6 +57,14 @@ export const UPDATE_CATEGORY = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORIES = `
|
||||||
|
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
|
||||||
|
updateCategories(input: { ids: $ids, patch: $patch }) {
|
||||||
|
categories { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const DELETE_CATEGORY = `
|
export const DELETE_CATEGORY = `
|
||||||
mutation DeleteCategory($id: Int!) {
|
mutation DeleteCategory($id: Int!) {
|
||||||
deleteCategory(input: { categoryId: $id }) {
|
deleteCategory(input: { categoryId: $id }) {
|
||||||
@@ -65,6 +81,16 @@ export const UPDATE_CATEGORY_ORDER = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_MANGA = `
|
||||||
|
mutation UpdateCategoryManga($categoryId: Int!) {
|
||||||
|
updateCategoryManga(input: { categoryId: $categoryId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const UPDATE_LIBRARY = `
|
export const UPDATE_LIBRARY = `
|
||||||
mutation UpdateLibrary {
|
mutation UpdateLibrary {
|
||||||
updateLibrary(input: {}) {
|
updateLibrary(input: {}) {
|
||||||
@@ -75,6 +101,26 @@ export const UPDATE_LIBRARY = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY_MANGA = `
|
||||||
|
mutation UpdateLibraryManga($mangaId: Int!) {
|
||||||
|
updateLibraryManga(input: { mangaId: $mangaId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_STOP = `
|
||||||
|
mutation UpdateStop {
|
||||||
|
updateStop(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const CREATE_BACKUP = `
|
export const CREATE_BACKUP = `
|
||||||
mutation CreateBackup {
|
mutation CreateBackup {
|
||||||
createBackup(input: {}) { url }
|
createBackup(input: {}) { url }
|
||||||
@@ -89,3 +135,19 @@ export const RESTORE_BACKUP = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SET_MANGA_META = `
|
||||||
|
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||||
|
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_MANGA_META = `
|
||||||
|
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
|
||||||
|
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
+101
-421
@@ -2,449 +2,129 @@
|
|||||||
|
|
||||||
## Manga (`mutations/manga.ts`)
|
## Manga (`mutations/manga.ts`)
|
||||||
|
|
||||||
### `FETCH_MANGA`
|
| Mutation | Variables | Description |
|
||||||
Fetches and refreshes manga metadata from its source.
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
|
||||||
**Variables:**
|
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
|
||||||
| Name | Type | Description |
|
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
|
||||||
|------|------|-------------|
|
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
|
||||||
| `id` | `Int!` | Manga ID |
|
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
|
||||||
|
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
|
||||||
---
|
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
|
||||||
|
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
|
||||||
### `UPDATE_MANGA`
|
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
|
||||||
Updates a single manga's library membership.
|
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
|
||||||
**Variables:**
|
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
|
||||||
| Name | Type | Description |
|
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|
||||||
|------|------|-------------|
|
| `UPDATE_STOP` | — | Stop the currently running library update job |
|
||||||
| `id` | `Int!` | Manga ID |
|
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
|
||||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
|
||||||
|
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
|
||||||
---
|
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
|
||||||
|
|
||||||
### `UPDATE_MANGAS`
|
|
||||||
Bulk-updates library membership for multiple manga.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Manga IDs |
|
|
||||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_MANGA_CATEGORIES`
|
|
||||||
Adds or removes a manga from categories.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
| `addTo` | `[Int!]!` | Category IDs to add to |
|
|
||||||
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CREATE_CATEGORY`
|
|
||||||
Creates a new manga category.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `name` | `String!` | Category name |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CATEGORY`
|
|
||||||
Updates a category's name.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
| `name` | `String` | New name |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DELETE_CATEGORY`
|
|
||||||
Deletes a category by ID.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CATEGORY_ORDER`
|
|
||||||
Moves a category to a new position.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
| `position` | `Int!` | New position index |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_LIBRARY`
|
|
||||||
Triggers a library-wide metadata refresh and returns job status.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CREATE_BACKUP`
|
|
||||||
Creates a backup and returns its download URL.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `RESTORE_BACKUP`
|
|
||||||
Restores a backup from an uploaded file and returns restore job status.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `backup` | `Upload!` | Backup file |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chapters (`mutations/chapters.ts`)
|
## Chapters (`mutations/chapters.ts`)
|
||||||
|
|
||||||
### `FETCH_CHAPTERS`
|
| Mutation | Variables | Description |
|
||||||
Fetches/refreshes the chapter list for a manga from its source.
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
|
||||||
**Variables:**
|
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
|
||||||
| Name | Type | Description |
|
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
|
||||||
|------|------|-------------|
|
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
|
||||||
|
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
|
||||||
---
|
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
|
||||||
|
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
|
||||||
### `FETCH_CHAPTER_PAGES`
|
|
||||||
Fetches the page URLs for a specific chapter.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `MARK_CHAPTER_READ`
|
|
||||||
Marks a single chapter as read or unread.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Chapter ID |
|
|
||||||
| `isRead` | `Boolean!` | Read state |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `MARK_CHAPTERS_READ`
|
|
||||||
Bulk-marks multiple chapters as read or unread.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
| `isRead` | `Boolean!` | Read state |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CHAPTERS_PROGRESS`
|
|
||||||
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
| `isRead` | `Boolean` | Read state |
|
|
||||||
| `isBookmarked` | `Boolean` | Bookmark state |
|
|
||||||
| `lastPageRead` | `Int` | Last page index read |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DELETE_DOWNLOADED_CHAPTERS`
|
|
||||||
Deletes downloaded chapter files for the given chapter IDs.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Downloads (`mutations/downloads.ts`)
|
## Downloads (`mutations/downloads.ts`)
|
||||||
|
|
||||||
### `ENQUEUE_DOWNLOAD`
|
| Mutation | Variables | Description |
|
||||||
Adds a single chapter to the download queue.
|
|----------|-----------|-------------|
|
||||||
|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
|
||||||
**Variables:**
|
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
|
||||||
| Name | Type | Description |
|
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|
||||||
|------|------|-------------|
|
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
|
||||||
|
| `START_DOWNLOADER` | — | Start the downloader |
|
||||||
---
|
| `STOP_DOWNLOADER` | — | Stop the downloader |
|
||||||
|
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
|
||||||
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
|
||||||
Adds multiple chapters to the download queue.
|
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
|
||||||
|
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DEQUEUE_DOWNLOAD`
|
|
||||||
Removes a chapter from the download queue.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `START_DOWNLOADER`
|
|
||||||
Starts the downloader and returns the current queue state.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `STOP_DOWNLOADER`
|
|
||||||
Stops the downloader and returns the current queue state.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CLEAR_DOWNLOADER`
|
|
||||||
Clears all items from the download queue.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `FETCH_SOURCE_MANGA`
|
|
||||||
Fetches manga from a source (browse/search), with pagination and optional filters.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `source` | `LongString!` | Source ID |
|
|
||||||
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
|
||||||
| `page` | `Int!` | Page number |
|
|
||||||
| `query` | `String` | Search query |
|
|
||||||
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_DOWNLOADS_PATH`
|
|
||||||
Sets the downloads directory path in settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `path` | `String!` | Filesystem path |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_LOCAL_SOURCE_PATH`
|
|
||||||
Sets the local source directory path in settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `path` | `String!` | Filesystem path |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Extensions (`mutations/extensions.ts`)
|
## Extensions (`mutations/extensions.ts`)
|
||||||
|
|
||||||
### `FETCH_EXTENSIONS`
|
| Mutation | Variables | Description |
|
||||||
Fetches the latest extension list from configured repos.
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
|
||||||
**Variables:** none
|
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
|
||||||
|
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
|
||||||
---
|
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
|
||||||
|
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
|
||||||
### `UPDATE_EXTENSION`
|
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
|
||||||
Installs, uninstalls, or updates an extension.
|
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
|
||||||
|
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
|
||||||
**Variables:**
|
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
|
||||||
| Name | Type | Description |
|
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
|
||||||
|------|------|-------------|
|
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
|
||||||
| `id` | `String!` | Extension package name |
|
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
|
||||||
| `install` | `Boolean` | Install the extension |
|
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
|
||||||
| `uninstall` | `Boolean` | Uninstall the extension |
|
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
|
||||||
| `update` | `Boolean` | Update the extension |
|
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
|
||||||
|
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
|
||||||
---
|
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
|
||||||
|
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
|
||||||
### `INSTALL_EXTERNAL_EXTENSION`
|
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
|
||||||
Installs an extension from an external APK URL.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `url` | `String!` | APK download URL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_EXTENSION_REPOS`
|
|
||||||
Sets the list of extension repository URLs.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `repos` | `[String!]!` | Repository URLs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_SERVER_AUTH`
|
|
||||||
Configures server authentication mode and credentials.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `authMode` | `AuthMode!` | Auth mode |
|
|
||||||
| `authUsername` | `String!` | Username |
|
|
||||||
| `authPassword` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_SOCKS_PROXY`
|
|
||||||
Configures SOCKS proxy settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
|
||||||
| `socksProxyHost` | `String!` | Proxy host |
|
|
||||||
| `socksProxyPort` | `String!` | Proxy port |
|
|
||||||
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
|
||||||
| `socksProxyUsername` | `String!` | Proxy username |
|
|
||||||
| `socksProxyPassword` | `String!` | Proxy password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_FLARESOLVERR`
|
|
||||||
Configures FlareSolverr integration settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
|
||||||
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
|
||||||
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
|
||||||
| `flareSolverrSessionName` | `String!` | Session name |
|
|
||||||
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
|
||||||
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tracking (`mutations/tracking.ts`)
|
## Tracking (`mutations/tracking.ts`)
|
||||||
|
|
||||||
### `BIND_TRACK`
|
| Mutation | Variables | Description |
|
||||||
Binds a manga to a remote tracker entry.
|
|----------|-----------|-------------|
|
||||||
|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
|
||||||
**Variables:**
|
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
|
||||||
| Name | Type | Description |
|
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|
||||||
|------|------|-------------|
|
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
|
||||||
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
|
||||||
|
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
|
||||||
|
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
|
||||||
|
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
|
||||||
|
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
|
||||||
|
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
|
||||||
|
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
|
||||||
|
| `REFRESH_TOKEN` | — | Refresh the current access token |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `UPDATE_TRACK`
|
## New in Preview
|
||||||
Updates tracking progress, status, score, and dates for a track record.
|
|
||||||
|
|
||||||
**Variables:**
|
Mutations now available and not yet wired to any feature in Moku:
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `recordId` | `Int!` | Track record ID |
|
|
||||||
| `status` | `Int` | Reading status |
|
|
||||||
| `lastChapterRead` | `Float` | Last chapter read |
|
|
||||||
| `scoreString` | `String` | Score in tracker's format |
|
|
||||||
| `startDate` | `LongString` | Start date |
|
|
||||||
| `finishDate` | `LongString` | Finish date |
|
|
||||||
| `private` | `Boolean` | Mark as private |
|
|
||||||
|
|
||||||
---
|
| Mutation | Potential Feature |
|
||||||
|
|----------|-------------------|
|
||||||
### `UNBIND_TRACK`
|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
|
||||||
Unbinds a manga from a tracker record.
|
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
|
||||||
**Variables:**
|
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
|
||||||
| Name | Type | Description |
|
| `UPDATE_STOP` | Cancel button for library update jobs |
|
||||||
|------|------|-------------|
|
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
|
||||||
| `recordId` | `Int!` | Track record ID |
|
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
|
||||||
|
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
|
||||||
---
|
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
|
||||||
|
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
|
||||||
### `FETCH_TRACK`
|
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
|
||||||
Refreshes a track record from the remote tracker.
|
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
|
||||||
|
| `RESET_SETTINGS` | Settings page — factory reset button |
|
||||||
**Variables:**
|
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
|
||||||
| Name | Type | Description |
|
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
|
||||||
|------|------|-------------|
|
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
|
||||||
| `recordId` | `Int!` | Track record ID |
|
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_TRACKER_OAUTH`
|
|
||||||
Initiates OAuth login for a tracker using a callback URL.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `callbackUrl` | `String!` | OAuth callback URL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_TRACKER_CREDENTIALS`
|
|
||||||
Logs into a tracker using username and password.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `username` | `String!` | Username |
|
|
||||||
| `password` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGOUT_TRACKER`
|
|
||||||
Logs out of a tracker.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_USER`
|
|
||||||
Authenticates a user and returns access and refresh tokens.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `username` | `String!` | Username |
|
|
||||||
| `password` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `REFRESH_TOKEN`
|
|
||||||
Refreshes the current access token.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const TRACK_RECORD_FRAGMENT = `
|
const TRACK_RECORD_FRAGMENT = `
|
||||||
id trackerId remoteId title status score displayScore
|
id trackerId remoteId title status score displayScore
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const BIND_TRACK = `
|
export const BIND_TRACK = `
|
||||||
@@ -15,7 +15,7 @@ export const UPDATE_TRACK = `
|
|||||||
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||||
trackRecord {
|
trackRecord {
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,17 @@ export const FETCH_TRACK = `
|
|||||||
mutation FetchTrack($recordId: Int!) {
|
mutation FetchTrack($recordId: Int!) {
|
||||||
fetchTrack(input: { recordId: $recordId }) {
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
trackRecord {
|
trackRecord {
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TRACK_PROGRESS = `
|
||||||
|
mutation TrackProgress($mangaId: Int!) {
|
||||||
|
trackProgress(input: { mangaId: $mangaId }) {
|
||||||
|
trackRecords {
|
||||||
|
id trackerId lastChapterRead status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +53,7 @@ export const LOGIN_TRACKER_OAUTH = `
|
|||||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
isLoggedIn
|
isLoggedIn
|
||||||
tracker { id name isLoggedIn authUrl }
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -52,7 +62,7 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
|||||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||||
isLoggedIn
|
isLoggedIn
|
||||||
tracker { id name isLoggedIn authUrl }
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -60,21 +70,58 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
|||||||
export const LOGOUT_TRACKER = `
|
export const LOGOUT_TRACKER = `
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
logoutTracker(input: { trackerId: $trackerId }) {
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
tracker { id name isLoggedIn authUrl }
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CONNECT_KOSYNC = `
|
||||||
|
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
|
||||||
|
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_KOSYNC = `
|
||||||
|
mutation LogoutKoSync {
|
||||||
|
logoutKoSyncAccount(input: {}) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PULL_KOSYNC_PROGRESS = `
|
||||||
|
mutation PullKoSyncProgress($chapterId: Int!) {
|
||||||
|
pullKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PUSH_KOSYNC_PROGRESS = `
|
||||||
|
mutation PushKoSyncProgress($chapterId: Int!) {
|
||||||
|
pushKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LOGIN_USER = `
|
export const LOGIN_USER = `
|
||||||
mutation Login($username: String!, $password: String!) {
|
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||||
login(input: { username: $username, password: $password }) {
|
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||||
accessToken refreshToken
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const REFRESH_TOKEN = `
|
export const REFRESH_TOKEN = `
|
||||||
mutation RefreshToken {
|
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
refreshToken { accessToken }
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -2,6 +2,12 @@ export const GET_RECENTLY_UPDATED = `
|
|||||||
query GetRecentlyUpdated {
|
query GetRecentlyUpdated {
|
||||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
nodes {
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
chapterNumber
|
||||||
|
sourceOrder
|
||||||
|
isRead
|
||||||
|
lastPageRead
|
||||||
mangaId
|
mangaId
|
||||||
fetchedAt
|
fetchedAt
|
||||||
manga { id title thumbnailUrl inLibrary }
|
manga { id title thumbnailUrl inLibrary }
|
||||||
@@ -15,7 +21,7 @@ export const GET_CHAPTERS = `
|
|||||||
chapters(condition: { mangaId: $mangaId }) {
|
chapters(condition: { mangaId: $mangaId }) {
|
||||||
nodes {
|
nodes {
|
||||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const GET_DOWNLOAD_STATUS = `
|
|||||||
downloadStatus {
|
downloadStatus {
|
||||||
state
|
state
|
||||||
queue {
|
queue {
|
||||||
progress state
|
progress state tries
|
||||||
chapter {
|
chapter {
|
||||||
id name pageCount mangaId
|
id name pageCount mangaId
|
||||||
manga { id title thumbnailUrl }
|
manga { id title thumbnailUrl }
|
||||||
|
|||||||
@@ -20,7 +20,81 @@ export const GET_EXTENSIONS = `
|
|||||||
export const GET_SOURCES = `
|
export const GET_SOURCES = `
|
||||||
query GetSources {
|
query GetSources {
|
||||||
sources {
|
sources {
|
||||||
nodes { id name lang displayName iconUrl isNsfw }
|
nodes {
|
||||||
|
id name lang displayName iconUrl isNsfw
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export * from "./chapters";
|
|||||||
export * from "./downloads";
|
export * from "./downloads";
|
||||||
export * from "./extensions";
|
export * from "./extensions";
|
||||||
export * from "./tracking";
|
export * from "./tracking";
|
||||||
|
export * from "./updater";
|
||||||
|
export * from "./meta";
|
||||||
@@ -2,10 +2,15 @@ export const GET_LIBRARY = `
|
|||||||
query GetLibrary {
|
query GetLibrary {
|
||||||
mangas(condition: { inLibrary: true }) {
|
mangas(condition: { inLibrary: true }) {
|
||||||
nodes {
|
nodes {
|
||||||
id title thumbnailUrl inLibrary downloadCount unreadCount
|
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||||
description status author artist genre
|
description status author artist genre
|
||||||
|
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
|
||||||
source { id name displayName }
|
source { id name displayName }
|
||||||
chapters { totalCount }
|
chapters { totalCount }
|
||||||
|
latestFetchedChapter { id uploadDate }
|
||||||
|
latestUploadedChapter { id uploadDate }
|
||||||
|
lastReadChapter { id chapterNumber }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +28,11 @@ export const GET_MANGA = `
|
|||||||
query GetManga($id: Int!) {
|
query GetManga($id: Int!) {
|
||||||
manga(id: $id) {
|
manga(id: $id) {
|
||||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
||||||
source { id name displayName }
|
source { id name displayName }
|
||||||
|
lastReadChapter { id chapterNumber lastPageRead }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
|
highestNumberedChapter { id chapterNumber }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -58,12 +67,17 @@ export const GET_DOWNLOADS_PATH = `
|
|||||||
export const LIBRARY_UPDATE_STATUS = `
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
query LibraryUpdateStatus {
|
query LibraryUpdateStatus {
|
||||||
libraryUpdateStatus {
|
libraryUpdateStatus {
|
||||||
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
|
jobsInfo {
|
||||||
|
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||||
|
}
|
||||||
mangaUpdates {
|
mangaUpdates {
|
||||||
status
|
status
|
||||||
manga { id title thumbnailUrl unreadCount }
|
manga { id title thumbnailUrl unreadCount }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastUpdateTimestamp {
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const GET_META = `
|
||||||
|
query GetMeta($key: String!) {
|
||||||
|
meta(key: $key) {
|
||||||
|
key value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_METAS = `
|
||||||
|
query GetMetas {
|
||||||
|
metas {
|
||||||
|
nodes { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
+77
-131
@@ -2,170 +2,116 @@
|
|||||||
|
|
||||||
## Manga (`queries/manga.ts`)
|
## Manga (`queries/manga.ts`)
|
||||||
|
|
||||||
### `GET_LIBRARY`
|
| Query | Variables | Description |
|
||||||
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
|
||||||
**Variables:** none
|
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
|
||||||
|
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
|
||||||
---
|
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
||||||
|
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||||
### `GET_ALL_MANGA`
|
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||||
Fetches all manga (library and non-library) with minimal fields.
|
| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
|
||||||
|
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
||||||
**Variables:** none
|
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
||||||
|
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_MANGA`
|
|
||||||
Fetches a single manga by ID with full metadata and source info.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_CATEGORIES`
|
|
||||||
Fetches all categories with their order, settings, and the manga assigned to each.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
|
||||||
Fetches page counts for all downloaded chapters.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_DOWNLOADS_PATH`
|
|
||||||
Fetches the configured downloads path and local source path from settings.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LIBRARY_UPDATE_STATUS`
|
|
||||||
Fetches the current library update job status, including progress and any manga with new chapters.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_RESTORE_STATUS`
|
|
||||||
Fetches the status of a backup restore operation by its job ID.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `String!` | Restore job ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `VALIDATE_BACKUP`
|
|
||||||
Validates a backup file and returns any missing sources or trackers.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `backup` | `Upload!` | Backup file |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chapters (`queries/chapters.ts`)
|
## Chapters (`queries/chapters.ts`)
|
||||||
|
|
||||||
### `GET_CHAPTERS`
|
| Query | Variables | Description |
|
||||||
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
|-------|-----------|-------------|
|
||||||
|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
|
||||||
**Variables:**
|
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Downloads (`queries/downloads.ts`)
|
## Downloads (`queries/downloads.ts`)
|
||||||
|
|
||||||
### `GET_DOWNLOAD_STATUS`
|
| Query | Variables | Description |
|
||||||
Fetches the current downloader state and full queue with chapter and manga info.
|
|-------|-----------|-------------|
|
||||||
|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Extensions (`queries/extensions.ts`)
|
## Extensions (`queries/extensions.ts`)
|
||||||
|
|
||||||
### `GET_EXTENSIONS`
|
| Query | Variables | Description |
|
||||||
Fetches all extensions with install status, update availability, and metadata.
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
|
||||||
**Variables:** none
|
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
|
||||||
|
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
|
||||||
---
|
| `GET_SETTINGS` | — | `extensionRepos` from settings |
|
||||||
|
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
|
||||||
### `GET_SOURCES`
|
|
||||||
Fetches all available sources with language and NSFW flags.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_SETTINGS`
|
|
||||||
Fetches extension repository settings.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_SERVER_SECURITY`
|
|
||||||
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tracking (`queries/tracking.ts`)
|
## Tracking (`queries/tracking.ts`)
|
||||||
|
|
||||||
### `GET_TRACKERS`
|
| Query | Variables | Description |
|
||||||
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
|-------|-----------|-------------|
|
||||||
|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
|
||||||
**Variables:** none
|
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
|
||||||
|
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
|
||||||
|
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
|
||||||
|
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET_MANGA_TRACK_RECORDS`
|
## Updater (`queries/updater.ts`)
|
||||||
Fetches all tracking records for a specific manga across all trackers.
|
|
||||||
|
|
||||||
**Variables:**
|
| Query | Variables | Description |
|
||||||
| Name | Type | Description |
|
|-------|-----------|-------------|
|
||||||
|------|------|-------------|
|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `SEARCH_TRACKER`
|
## Meta (`queries/meta.ts`)
|
||||||
Searches a tracker for manga by query string.
|
|
||||||
|
|
||||||
**Variables:**
|
| Query | Variables | Description |
|
||||||
| Name | Type | Description |
|
|-------|-----------|-------------|
|
||||||
|------|------|-------------|
|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
| `GET_METAS` | — | All global meta entries as a node list |
|
||||||
| `query` | `String!` | Search query |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET_ALL_TRACKER_RECORDS`
|
## KoSync (`queries/kosync.ts`)
|
||||||
Fetches all trackers and their full track records, including associated manga info.
|
|
||||||
|
|
||||||
**Variables:** none
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET_TRACKER_RECORDS`
|
## New in Preview
|
||||||
Fetches track records for a specific tracker.
|
|
||||||
|
|
||||||
**Variables:**
|
Queries and fields now available but not yet wired to any feature in Moku:
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
| Query / Field | Potential Feature |
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|---------------|-------------------|
|
||||||
|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
|
||||||
|
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
|
||||||
|
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
|
||||||
|
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
|
||||||
|
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
|
||||||
|
| `category` (single by id) | Direct category detail without fetching all categories |
|
||||||
|
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
|
||||||
|
| `source` (single by id) | Source detail page — preferences, filters, browse |
|
||||||
|
| `tracker` (single by id) | Individual tracker detail — statuses, records |
|
||||||
|
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
|
||||||
|
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
|
||||||
|
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
|
||||||
|
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
|
||||||
|
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
|
||||||
|
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
|
||||||
|
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
|
||||||
|
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
|
||||||
|
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
|
||||||
|
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
|
||||||
@@ -2,7 +2,9 @@ export const GET_TRACKERS = `
|
|||||||
query GetTrackers {
|
query GetTrackers {
|
||||||
trackers {
|
trackers {
|
||||||
nodes {
|
nodes {
|
||||||
id name icon isLoggedIn authUrl supportsPrivateTracking scores
|
id name icon isLoggedIn isTokenExpired authUrl
|
||||||
|
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||||
|
scores
|
||||||
statuses { value name }
|
statuses { value name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,7 @@ export const GET_MANGA_TRACK_RECORDS = `
|
|||||||
trackRecords {
|
trackRecords {
|
||||||
nodes {
|
nodes {
|
||||||
id trackerId remoteId title status score displayScore
|
id trackerId remoteId title status score displayScore
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +39,12 @@ export const GET_ALL_TRACKER_RECORDS = `
|
|||||||
query GetAllTrackerRecords {
|
query GetAllTrackerRecords {
|
||||||
trackers {
|
trackers {
|
||||||
nodes {
|
nodes {
|
||||||
id name icon isLoggedIn scores
|
id name icon isLoggedIn isTokenExpired scores
|
||||||
statuses { value name }
|
statuses { value name }
|
||||||
trackRecords {
|
trackRecords {
|
||||||
nodes {
|
nodes {
|
||||||
id trackerId title status displayScore lastChapterRead
|
id trackerId title status displayScore lastChapterRead
|
||||||
totalChapters remoteUrl private
|
totalChapters remoteUrl private libraryId
|
||||||
manga { id title thumbnailUrl inLibrary }
|
manga { id title thumbnailUrl inLibrary }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const GET_ABOUT_SERVER = `
|
||||||
|
query GetAboutServer {
|
||||||
|
aboutServer {
|
||||||
|
name version buildType buildTime github discord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ABOUT_WEBUI = `
|
||||||
|
query GetAboutWebUI {
|
||||||
|
aboutWebUI {
|
||||||
|
channel tag updateTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CHECK_FOR_SERVER_UPDATES = `
|
||||||
|
query CheckForServerUpdates {
|
||||||
|
checkForServerUpdates {
|
||||||
|
channel tag url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
/**
|
|
||||||
* {@attach selectPortal(triggerEl)}
|
|
||||||
*
|
|
||||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
|
||||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
|
||||||
*
|
|
||||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
|
||||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
|
||||||
*/
|
|
||||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||||
return (menuEl: HTMLElement) => {
|
return (menuEl: HTMLElement) => {
|
||||||
// Position & move to body
|
|
||||||
function position() {
|
function position() {
|
||||||
|
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||||
const r = triggerEl.getBoundingClientRect();
|
const r = triggerEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
const top = r.bottom / zoom + 4;
|
||||||
|
const right = r.right / zoom;
|
||||||
|
const width = menuEl.offsetWidth;
|
||||||
|
const left = Math.max(8, right - width);
|
||||||
|
|
||||||
menuEl.style.position = "fixed";
|
menuEl.style.position = "fixed";
|
||||||
menuEl.style.top = `${r.bottom + 4}px`;
|
menuEl.style.top = `${top}px`;
|
||||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
menuEl.style.left = `${left}px`;
|
||||||
// clamp to viewport left edge
|
|
||||||
const left = parseFloat(menuEl.style.left);
|
|
||||||
if (left < 8) menuEl.style.left = "8px";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menuEl.style.visibility = "hidden";
|
||||||
document.body.appendChild(menuEl);
|
document.body.appendChild(menuEl);
|
||||||
triggerEl.__selectMenuEl = menuEl;
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
position();
|
|
||||||
|
|
||||||
// Reposition on scroll / resize while open
|
requestAnimationFrame(() => {
|
||||||
|
position();
|
||||||
|
menuEl.style.visibility = "";
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("scroll", position, true);
|
window.addEventListener("scroll", position, true);
|
||||||
window.addEventListener("resize", position);
|
window.addEventListener("resize", position);
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
import type { Manga, Source } from "@types";
|
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
|
||||||
import type { Settings } from "@types";
|
|
||||||
import { shouldHideSource } from "@core/util";
|
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name, preferring `preferredLang` when multiple
|
|
||||||
* sources share a name. The local source (id "0") is always excluded.
|
|
||||||
*
|
|
||||||
* When `applyHide` is true, sources that fail the NSFW/block check are
|
|
||||||
* also removed — used in fan-out and cache-build paths where only
|
|
||||||
* user-visible sources should be queried.
|
|
||||||
*/
|
|
||||||
export function dedupeSourcesByLang(
|
|
||||||
sources: Source[],
|
|
||||||
preferredLang: string,
|
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
|
||||||
applyHide = false,
|
|
||||||
): Source[] {
|
|
||||||
const map = new Map<string, Source>();
|
|
||||||
for (const s of sources) {
|
|
||||||
if (s.id === "0") continue;
|
|
||||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
|
||||||
const existing = map.get(s.name);
|
|
||||||
if (!existing) { map.set(s.name, s); continue; }
|
|
||||||
const existingPref = existing.lang === preferredLang;
|
|
||||||
const newPref = s.lang === preferredLang;
|
|
||||||
if (newPref && !existingPref) map.set(s.name, s);
|
|
||||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Manga predicate filters ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic predicate pipeline — composes multiple boolean predicates into one.
|
|
||||||
* All predicates must return true for an item to pass.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const keep = buildFilter<Manga>(
|
|
||||||
* m => !shouldHideNsfw(m, settings),
|
|
||||||
* m => m.inLibrary,
|
|
||||||
* );
|
|
||||||
* const filtered = items.filter(keep);
|
|
||||||
*/
|
|
||||||
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||||
return (item) => predicates.every((p) => p(item));
|
return (item) => predicates.every((p) => p(item));
|
||||||
}
|
}
|
||||||
+567
-23
@@ -1,10 +1,293 @@
|
|||||||
import { store, updateSettings } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||||
|
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(msg = "Authentication required") {
|
||||||
|
super(msg);
|
||||||
|
this.name = "AuthRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = "moku_access_token";
|
||||||
|
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 = {
|
||||||
|
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 = {
|
export const authSession = {
|
||||||
clearTokens() {},
|
clearTokens() {
|
||||||
hasSession(): boolean { return true; },
|
_refreshPromise = null;
|
||||||
|
_jwtSettings = null;
|
||||||
|
_jwtSettingsBase = null;
|
||||||
|
_jwtSettingsFetchedAt = 0;
|
||||||
|
uiAuth.clearToken();
|
||||||
|
},
|
||||||
|
hasSession(): boolean {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function getServerBase(): string {
|
function getServerBase(): string {
|
||||||
@@ -12,6 +295,61 @@ function getServerBase(): string {
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredToken(): StoredAccessToken | null {
|
||||||
|
const session = readStoredSession();
|
||||||
|
if (session) return { base: session.base, token: session.accessToken };
|
||||||
|
|
||||||
|
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
|
||||||
|
return { base: parsed.base, token: parsed.token };
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated = { base: getServerBase(), token: raw.trim() };
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSession(): StoredUiAuthSession | null {
|
||||||
|
const raw = sessionStorage.getItem(UI_SESSION_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
|
||||||
|
return {
|
||||||
|
base: parsed.base,
|
||||||
|
accessToken: parsed.accessToken,
|
||||||
|
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
|
||||||
|
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
|
||||||
|
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
|
||||||
|
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!legacy?.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(legacy);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
|
||||||
|
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
function timeoutSignal(ms: number): AbortSignal {
|
function timeoutSignal(ms: number): AbortSignal {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTimeout(() => controller.abort(), ms);
|
setTimeout(() => controller.abort(), ms);
|
||||||
@@ -22,24 +360,231 @@ function basicHeader(user: string, pass: string): Record<string, string> {
|
|||||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
function bearerHeader(token: string): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthenticated(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
skipped = false,
|
||||||
|
): Promise<Response> {
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||||
|
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...init, signal, credentials: "omit",
|
...init, signal, credentials: "omit",
|
||||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
|
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "UI_LOGIN") {
|
||||||
|
const token = await getUIAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
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
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ username: user, password: pass },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(8000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||||
|
const json = await res.json();
|
||||||
|
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> {
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
method: "POST", credentials: "omit",
|
method: "POST", credentials: "omit",
|
||||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: gqlBody("{ __typename }"),
|
||||||
signal: timeoutSignal(5000),
|
signal: timeoutSignal(5000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
@@ -47,39 +592,38 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
updateSettings({ serverAuthPass: "" });
|
uiAuth.clearToken();
|
||||||
|
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||||
const base = getServerBase();
|
const base = getServerBase();
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
const s = store.settings;
|
const s = store.settings;
|
||||||
|
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
||||||
|
|
||||||
|
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = s.serverAuthUser?.trim() ?? "";
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
const pass = s.serverAuthPass?.trim() ?? "";
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
|
} else if (mode === "UI_LOGIN" && token) {
|
||||||
|
Object.assign(headers, bearerHeader(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST", credentials: "omit", headers,
|
method: "POST", credentials: "omit", headers,
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: gqlBody("{ __typename }"),
|
||||||
signal: timeoutSignal(5000),
|
signal: timeoutSignal(5000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) return "ok";
|
if (res.ok) return "ok";
|
||||||
if (res.status === 401) {
|
if (res.status === 401) return "auth_required";
|
||||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
return "unreachable";
|
||||||
if (/basic/i.test(wwwAuth)) {
|
} catch {
|
||||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
|
||||||
return "auth_required";
|
|
||||||
}
|
|
||||||
if (/bearer/i.test(wwwAuth)) {
|
|
||||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
|
||||||
} else if (mode === "NONE") {
|
|
||||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
|
||||||
}
|
|
||||||
return "unsupported_mode";
|
|
||||||
}
|
|
||||||
return "unreachable";
|
return "unreachable";
|
||||||
} catch { return "unreachable"; }
|
}
|
||||||
}
|
}
|
||||||
+241
-23
@@ -1,38 +1,256 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
persistSettings,
|
||||||
|
persistLibrary,
|
||||||
|
persistUpdates,
|
||||||
|
} from "@core/persistence/persist";
|
||||||
|
|
||||||
function collectAppData(): Record<string, string> {
|
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
|
||||||
const data: Record<string, string> = {};
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAppData(data: Record<string, string>): void {
|
|
||||||
localStorage.clear();
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportAppData(): Promise<void> {
|
export async function exportAppData(): Promise<void> {
|
||||||
const json = JSON.stringify(collectAppData(), null, 2);
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
await invoke("export_app_data", { json });
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await invoke("export_app_data", { bytes: Array.from(zip) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importAppData(): Promise<void> {
|
export async function importAppData(): Promise<void> {
|
||||||
const json = await invoke<string>("import_app_data");
|
const raw: number[] = await invoke("import_app_data");
|
||||||
const data: Record<string, string> = JSON.parse(json);
|
const files = parseZip(new Uint8Array(raw));
|
||||||
applyAppData(data);
|
|
||||||
location.reload();
|
const decode = (name: string) => {
|
||||||
|
const bytes = files.get(name);
|
||||||
|
if (!bytes) throw new Error(`Backup is missing ${name}`);
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = decode("settings.json");
|
||||||
|
const l = decode("library.json");
|
||||||
|
const u = decode("updates.json");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
persistSettings({
|
||||||
|
settings: s.settings ?? null,
|
||||||
|
storeVersion: s.storeVersion ?? 1,
|
||||||
|
}),
|
||||||
|
persistLibrary({
|
||||||
|
history: l.history ?? [],
|
||||||
|
bookmarks: l.bookmarks ?? [],
|
||||||
|
markers: l.markers ?? [],
|
||||||
|
readLog: l.readLog ?? [],
|
||||||
|
readingStats: l.readingStats ?? null,
|
||||||
|
dailyReadCounts: l.dailyReadCounts ?? {},
|
||||||
|
}),
|
||||||
|
persistUpdates({
|
||||||
|
libraryUpdates: u.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await showExitModal();
|
||||||
|
invoke("exit_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExitModal(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "s-backdrop";
|
||||||
|
backdrop.style.cssText = "z-index:99999";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.style.cssText = [
|
||||||
|
"background:var(--bg-surface)",
|
||||||
|
"border:1px solid var(--border-base)",
|
||||||
|
"border-radius:var(--radius-2xl)",
|
||||||
|
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
"width:min(400px,calc(100vw - 40px))",
|
||||||
|
"display:flex",
|
||||||
|
"flex-direction:column",
|
||||||
|
"overflow:hidden",
|
||||||
|
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
|
||||||
|
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
|
||||||
|
title.textContent = "Import complete";
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
|
||||||
|
|
||||||
|
const sub = document.createElement("p");
|
||||||
|
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
|
||||||
|
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
|
||||||
|
|
||||||
|
const counter = document.createElement("p");
|
||||||
|
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
|
||||||
|
counter.textContent = "Closing in 3…";
|
||||||
|
|
||||||
|
body.append(sub, counter);
|
||||||
|
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "s-btn s-btn-danger";
|
||||||
|
btn.textContent = "Close now";
|
||||||
|
|
||||||
|
footer.appendChild(btn);
|
||||||
|
modal.append(header, body, footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
let secs = 3;
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
secs--;
|
||||||
|
counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…";
|
||||||
|
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function autoBackupAppData(): Promise<void> {
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(collectAppData());
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
await invoke("auto_backup_app_data", { json });
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[moku] auto-backup failed:", e);
|
console.warn("[moku] auto-backup failed:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crc32(data: Uint8Array): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of data) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(30 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x04034b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint32(14, crc32(data), true);
|
||||||
|
v.setUint32(18, data.byteLength, true);
|
||||||
|
v.setUint32(22, data.byteLength, true);
|
||||||
|
v.setUint16(26, name.byteLength, true);
|
||||||
|
v.setUint16(28, 0, true);
|
||||||
|
new Uint8Array(buf).set(name, 30);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(46 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x02014b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 20, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint16(14, 0, true);
|
||||||
|
v.setUint32(16, crc32(data), true);
|
||||||
|
v.setUint32(20, data.byteLength, true);
|
||||||
|
v.setUint32(24, data.byteLength, true);
|
||||||
|
v.setUint16(28, name.byteLength, true);
|
||||||
|
v.setUint16(30, 0, true);
|
||||||
|
v.setUint16(32, 0, true);
|
||||||
|
v.setUint16(34, 0, true);
|
||||||
|
v.setUint16(36, 0, true);
|
||||||
|
v.setUint32(38, 0, true);
|
||||||
|
v.setUint32(42, offset, true);
|
||||||
|
new Uint8Array(buf).set(name, 46);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(22);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x06054b50, true);
|
||||||
|
v.setUint16(4, 0, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, count, true);
|
||||||
|
v.setUint16(10, count, true);
|
||||||
|
v.setUint32(12, cdSize, true);
|
||||||
|
v.setUint32(16, cdOffset, true);
|
||||||
|
v.setUint16(20, 0, true);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
const offsets: number[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
for (const { name, bytes } of files) {
|
||||||
|
const nameBytes = enc.encode(name);
|
||||||
|
const lh = localHeader(nameBytes, bytes);
|
||||||
|
offsets.push(pos);
|
||||||
|
parts.push(lh, bytes);
|
||||||
|
pos += lh.byteLength + bytes.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdParts = files.map(({ name, bytes }, i) =>
|
||||||
|
centralHeader(enc.encode(name), bytes, offsets[i])
|
||||||
|
);
|
||||||
|
const cd = concat(cdParts);
|
||||||
|
|
||||||
|
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
|
||||||
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
|
||||||
|
const fnLen = view.getUint16(pos + 26, true);
|
||||||
|
const exLen = view.getUint16(pos + 28, true);
|
||||||
|
const cSize = view.getUint32(pos + 18, true);
|
||||||
|
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
|
||||||
|
const start = pos + 30 + fnLen + exLen;
|
||||||
|
files.set(name, data.subarray(start, start + cSize));
|
||||||
|
pos = start + cSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let pos = 0;
|
||||||
|
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Vendored
+35
-5
@@ -1,11 +1,13 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
|
import { getUIAccessToken } from "@core/auth";
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
const MAX_CONCURRENT = 6;
|
const MAX_CONCURRENT = 6;
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let drainScheduled = false;
|
let drainScheduled = false;
|
||||||
|
let clearing = false;
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -16,16 +18,27 @@ interface QueueEntry {
|
|||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "UI_LOGIN") {
|
||||||
|
const token = await getUIAccessToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
async function doFetch(url: string): Promise<string> {
|
async function doFetch(url: string): Promise<string> {
|
||||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await tauriFetch(url, { method: "GET", headers });
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
const blobUrl = URL.createObjectURL(await res.blob());
|
const blob = await res.blob();
|
||||||
|
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
cache.set(url, blobUrl);
|
cache.set(url, blobUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
}
|
}
|
||||||
@@ -47,7 +60,7 @@ function drain() {
|
|||||||
active++;
|
active++;
|
||||||
doFetch(entry.url)
|
doFetch(entry.url)
|
||||||
.then(entry.resolve, entry.reject)
|
.then(entry.resolve, entry.reject)
|
||||||
.finally(() => { inflight.delete(entry.url); active--; drain(); });
|
.finally(() => { active--; drain(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +71,12 @@ function scheduleDrain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(url: string, priority: number): Promise<string> {
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); });
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
|
insertSorted({ url, priority, resolve, reject });
|
||||||
|
}).catch(err => {
|
||||||
|
inflight.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
inflight.set(url, promise);
|
inflight.set(url, promise);
|
||||||
scheduleDrain();
|
scheduleDrain();
|
||||||
return promise;
|
return promise;
|
||||||
@@ -98,7 +116,19 @@ export function deprioritizeQueue(): void {
|
|||||||
queue.sort((a, b) => b.priority - a.priority);
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelQueuedFetches(): void {
|
||||||
|
const dropped = queue.splice(0);
|
||||||
|
for (const entry of dropped) {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
entry.reject(new DOMException("Cancelled", "AbortError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
|
clearing = true;
|
||||||
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
clearing = false;
|
||||||
}
|
}
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
interface MemEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryCache<T> {
|
||||||
|
readonly #cap: number;
|
||||||
|
readonly #ttl: number;
|
||||||
|
readonly #map = new Map<string, MemEntry<T>>();
|
||||||
|
|
||||||
|
constructor(capacity: number, ttlMs: number) {
|
||||||
|
this.#cap = capacity;
|
||||||
|
this.#ttl = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | undefined {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||||
|
this.#map.delete(key);
|
||||||
|
this.#map.set(key, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: T): void {
|
||||||
|
if (this.#map.has(key)) this.#map.delete(key);
|
||||||
|
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||||
|
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string): boolean {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void { this.#map.delete(key); }
|
||||||
|
|
||||||
|
clear(): void { this.#map.clear(); }
|
||||||
|
|
||||||
|
get size(): number { return this.#map.size; }
|
||||||
|
}
|
||||||
Vendored
+17
-12
@@ -1,4 +1,4 @@
|
|||||||
import { gql, plainThumbUrl } from "@api/client";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import { dedupeRequest } from "@core/async/batchRequests";
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
@@ -6,13 +6,18 @@ import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
|||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
const preloadedUrls = new Set<string>();
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
if (!useBlob) return Promise.resolve(url);
|
if (!useBlob) return Promise.resolve(url);
|
||||||
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
const cached = resolvedUrlCache.get(url);
|
||||||
return resolvedUrlCache.get(url)!;
|
if (cached) return cached;
|
||||||
|
const p = getBlobUrl(url, priority).catch(err => {
|
||||||
|
resolvedUrlCache.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
resolvedUrlCache.set(url, p);
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPages(
|
export function fetchPages(
|
||||||
@@ -29,11 +34,8 @@ export function fetchPages(
|
|||||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||||
.then(d => {
|
.then(d => {
|
||||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
|
||||||
if (useBlob) {
|
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
|
||||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
|
||||||
}
|
|
||||||
pageCache.set(chapterId, urls);
|
pageCache.set(chapterId, urls);
|
||||||
return urls;
|
return urls;
|
||||||
})
|
})
|
||||||
@@ -60,11 +62,15 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function preloadImage(url: string, useBlob: boolean): void {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (preloadedUrls.has(url)) return;
|
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||||
preloadedUrls.add(url);
|
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
@@ -73,7 +79,6 @@ export function clearPageCache(chapterId?: number): void {
|
|||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
resolvedUrlCache.clear();
|
resolvedUrlCache.clear();
|
||||||
preloadedUrls.clear();
|
|
||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+86
-6
@@ -1,10 +1,13 @@
|
|||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
|
fetcher?: () => Promise<T>;
|
||||||
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = new Map<string, Entry<unknown>>();
|
const store = new Map<string, Entry<unknown>>();
|
||||||
const subs = new Map<string, Set<() => void>>();
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
const keyToGroups = new Map<string, Set<string>>();
|
||||||
const groups = new Map<string, Set<string>>();
|
const groups = new Map<string, Set<string>>();
|
||||||
|
|
||||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
|
|||||||
for (const tag of Array.isArray(group) ? group : [group]) {
|
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
groups.get(tag)!.add(key);
|
groups.get(tag)!.add(key);
|
||||||
|
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||||
|
keyToGroups.get(key)!.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterKey(key: string) {
|
||||||
|
const tags = keyToGroups.get(key);
|
||||||
|
if (tags) {
|
||||||
|
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||||
|
keyToGroups.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +40,20 @@ export const cache = {
|
|||||||
if (err?.name !== "AbortError") store.delete(key);
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}) as Promise<T>;
|
}) as Promise<T>;
|
||||||
store.set(key, { promise, fetchedAt: Date.now() });
|
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||||
registerGroups(key, group);
|
registerGroups(key, group);
|
||||||
promise.then(() => notify(key)).catch(() => {});
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
set<T>(key: string, value: T, group?: string | string[]) {
|
set<T>(key: string, value: T, group?: string | string[]) {
|
||||||
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
store.set(key, {
|
||||||
|
promise: Promise.resolve(value),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
fetcher: existing?.fetcher,
|
||||||
|
ttl: existing?.ttl,
|
||||||
|
});
|
||||||
registerGroups(key, group);
|
registerGroups(key, group);
|
||||||
notify(key);
|
notify(key);
|
||||||
},
|
},
|
||||||
@@ -43,10 +62,38 @@ export const cache = {
|
|||||||
const existing = store.get(key) as Entry<T> | undefined;
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const next = existing.promise.then(fn);
|
const next = existing.promise.then(fn);
|
||||||
store.set(key, { promise: next, fetchedAt: Date.now() });
|
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||||
next.then(() => notify(key)).catch(() => {});
|
next.then(() => notify(key)).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refresh<T>(key: string): Promise<T> | undefined {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing?.fetcher) return undefined;
|
||||||
|
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshGroup(tag: string): void {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of [...keys]) {
|
||||||
|
const existing = store.get(key);
|
||||||
|
if (existing?.fetcher) {
|
||||||
|
const promise = existing.fetcher().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
has(key: string): boolean { return store.has(key); },
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
ageOf(key: string): number | undefined {
|
ageOf(key: string): number | undefined {
|
||||||
@@ -54,18 +101,35 @@ export const cache = {
|
|||||||
return e ? Date.now() - e.fetchedAt : undefined;
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
clear(key: string) { store.delete(key); notify(key); },
|
isStale(key: string): boolean {
|
||||||
|
const e = store.get(key);
|
||||||
|
if (!e) return true;
|
||||||
|
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(key: string) {
|
||||||
|
unregisterKey(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
clearGroup(tag: string) {
|
clearGroup(tag: string) {
|
||||||
const keys = groups.get(tag);
|
const keys = groups.get(tag);
|
||||||
if (!keys) return;
|
if (!keys) return;
|
||||||
for (const key of keys) { store.delete(key); notify(key); }
|
for (const key of [...keys]) {
|
||||||
|
keyToGroups.get(key)?.delete(tag);
|
||||||
|
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
}
|
||||||
groups.delete(tag);
|
groups.delete(tag);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
const allKeys = [...store.keys()];
|
const allKeys = [...store.keys()];
|
||||||
store.clear(); groups.clear();
|
store.clear();
|
||||||
|
groups.clear();
|
||||||
|
keyToGroups.clear();
|
||||||
allKeys.forEach(notify);
|
allKeys.forEach(notify);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,6 +147,7 @@ export const CACHE_GROUPS = {
|
|||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
|
RECENT_UPDATES: "recent_updates",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
CATEGORIES: "categories",
|
CATEGORIES: "categories",
|
||||||
SEARCH: "search_all_manga",
|
SEARCH: "search_all_manga",
|
||||||
@@ -159,3 +224,18 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||||
|
revokeBlobUrl(thumbnailUrl);
|
||||||
|
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { store, linkManga } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
|
||||||
|
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||||
|
{ type: "module" },
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||||
|
const matches = e.data;
|
||||||
|
for (const id of matches) linkManga(focal.id, id);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(matches.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = () => { worker.terminate(); resolve(0); };
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
focalTitle: focal.title,
|
||||||
|
focalId: focal.id,
|
||||||
|
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
||||||
|
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface WorkerMsg {
|
||||||
|
focalTitle: string;
|
||||||
|
focalId: number;
|
||||||
|
allManga: { id: number; title: string }[];
|
||||||
|
linkedIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wa = new Set(norm(a));
|
||||||
|
const wb = new Set(norm(b));
|
||||||
|
if (!wa.size || !wb.size) return 0;
|
||||||
|
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||||
|
return intersection / new Set([...wa, ...wb]).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||||
|
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||||
|
const matches: number[] = [];
|
||||||
|
|
||||||
|
for (const m of allManga) {
|
||||||
|
if (m.id === focalId) continue;
|
||||||
|
if (linkedIds.includes(m.id)) continue;
|
||||||
|
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage(matches);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const THUMB_SIZE = 16;
|
||||||
|
const DUPE_THRESH = 0.12;
|
||||||
|
|
||||||
|
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||||
|
|
||||||
|
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
||||||
|
const gray = new Uint8ClampedArray(pixels);
|
||||||
|
for (let i = 0; i < pixels; i++) {
|
||||||
|
const o = i * 4;
|
||||||
|
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||||
|
}
|
||||||
|
return gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvas.height = THUMB_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||||
|
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||||
|
return diff / (a.length * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
||||||
|
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||||
|
try {
|
||||||
|
const thumb = await loadThumb(url);
|
||||||
|
hashCache.set(url, thumb);
|
||||||
|
return thumb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||||
|
return similarity(a, b) <= DUPE_THRESH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHashCache(): void {
|
||||||
|
hashCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { searchWithScore } from "@core/algorithms/search";
|
||||||
|
import { getHash, areDuplicates } from "@core/cover/coverHash";
|
||||||
|
|
||||||
|
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
||||||
|
|
||||||
|
export type CoverCandidate = {
|
||||||
|
mangaId: number;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FUZZY_SCORE_THRESHOLD = 0.65;
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.search = "";
|
||||||
|
return u.href.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
||||||
|
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzyMatchIds(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): number[] {
|
||||||
|
const results = searchWithScore(
|
||||||
|
[...mangaById.values()].filter(m => m.id !== mangaId),
|
||||||
|
title,
|
||||||
|
m => m.title,
|
||||||
|
);
|
||||||
|
return results
|
||||||
|
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
||||||
|
.map(r => r.item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverCandidatesSync(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
ownUrl: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): CoverCandidate[] {
|
||||||
|
const linkedIds = store.getLinkedMangaIds(mangaId);
|
||||||
|
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
||||||
|
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
|
||||||
|
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
||||||
|
|
||||||
|
const raw: { mangaId: number; url: string; label: string }[] = [
|
||||||
|
{ mangaId, url: ownUrl, label: "This source" },
|
||||||
|
...allIds.flatMap(id => {
|
||||||
|
const m = mangaById.get(id);
|
||||||
|
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return raw
|
||||||
|
.filter(c => {
|
||||||
|
const key = normalizeUrl(c.url);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
||||||
|
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
||||||
|
|
||||||
|
const groups: number[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const hi = hashes[i];
|
||||||
|
const existing = hi
|
||||||
|
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
|
||||||
|
: undefined;
|
||||||
|
if (existing) existing.push(i);
|
||||||
|
else groups.push([i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map(group => {
|
||||||
|
const active = group.find(i => candidates[i].isActive) ?? group[0];
|
||||||
|
const labels = [...new Set(group.map(i => candidates[i].label))];
|
||||||
|
return { ...candidates[active], label: labels.join(" · ") };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
||||||
|
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
||||||
|
export type { CoverCandidate } from "./coverResolver";
|
||||||
|
export { autoLinkLibrary } from "./autoLink";
|
||||||
@@ -12,6 +12,7 @@ export interface Keybinds {
|
|||||||
openSettings: string;
|
openSettings: string;
|
||||||
toggleBookmark: string;
|
toggleBookmark: string;
|
||||||
toggleMarker: string;
|
toggleMarker: string;
|
||||||
|
toggleAutoScroll: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
|||||||
openSettings: "o",
|
openSettings: "o",
|
||||||
toggleBookmark: "m",
|
toggleBookmark: "m",
|
||||||
toggleMarker: "n",
|
toggleMarker: "n",
|
||||||
|
toggleAutoScroll: "s",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
@@ -44,4 +46,5 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
|||||||
openSettings: "Open settings",
|
openSettings: "Open settings",
|
||||||
toggleBookmark: "Toggle bookmark",
|
toggleBookmark: "Toggle bookmark",
|
||||||
toggleMarker: "Toggle marker",
|
toggleMarker: "Toggle marker",
|
||||||
|
toggleAutoScroll: "Toggle auto scroll",
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const VAULT_KEY = "moku-credential-vault";
|
||||||
|
const SALT_ITERATIONS = 200_000;
|
||||||
|
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||||
|
|
||||||
|
export interface VaultPayload {
|
||||||
|
refreshToken?: string;
|
||||||
|
basicUser?: string;
|
||||||
|
basicPass?: string;
|
||||||
|
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredVault {
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toB64(buf: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromB64(s: string): Uint8Array {
|
||||||
|
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||||
|
keyMat,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
KEY_USAGE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vaultExists(): boolean {
|
||||||
|
return !!localStorage.getItem(VAULT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const key = await deriveKey(pin, salt);
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const cipher = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
key,
|
||||||
|
enc.encode(JSON.stringify(payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||||
|
salt: toB64(salt),
|
||||||
|
iv: toB64(iv),
|
||||||
|
data: toB64(cipher),
|
||||||
|
} satisfies StoredVault));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||||
|
const raw = localStorage.getItem(VAULT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(raw) as StoredVault;
|
||||||
|
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||||
|
const plain = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||||
|
key,
|
||||||
|
fromB64(stored.data),
|
||||||
|
);
|
||||||
|
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearVault(): void {
|
||||||
|
localStorage.removeItem(VAULT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||||
|
const payload = await unlockVault(oldPin);
|
||||||
|
if (!payload) return false;
|
||||||
|
await lockVault(newPin, payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||||
|
export type { PersistedData } from "./persist";
|
||||||
|
|
||||||
|
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||||
|
export type { VaultPayload } from "./credentialVault";
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { LazyStore } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
||||||
|
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
||||||
|
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
||||||
|
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
||||||
|
|
||||||
|
export interface PersistedData {
|
||||||
|
settings: any;
|
||||||
|
storeVersion: number | null;
|
||||||
|
history: any[];
|
||||||
|
bookmarks: any[];
|
||||||
|
markers: any[];
|
||||||
|
readLog: any[];
|
||||||
|
readingStats: any | null;
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
libraryUpdates: any[];
|
||||||
|
lastLibraryRefresh: number;
|
||||||
|
acknowledgedUpdateIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllStores(): Promise<PersistedData> {
|
||||||
|
const migrated = await migrateFromLocalStorage();
|
||||||
|
if (migrated) return migrated;
|
||||||
|
|
||||||
|
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
|
||||||
|
settingsStore.get<number>("storeVersion"),
|
||||||
|
settingsStore.get<any>("settings"),
|
||||||
|
libraryStore.get<any[]>("history"),
|
||||||
|
libraryStore.get<any[]>("bookmarks"),
|
||||||
|
libraryStore.get<any[]>("markers"),
|
||||||
|
libraryStore.get<any[]>("readLog"),
|
||||||
|
libraryStore.get<any>("readingStats"),
|
||||||
|
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
||||||
|
updatesStore.get<any[]>("libraryUpdates"),
|
||||||
|
updatesStore.get<number>("lastLibraryRefresh"),
|
||||||
|
updatesStore.get<number[]>("acknowledgedUpdateIds"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeVersion: sv ?? null,
|
||||||
|
settings: s ?? null,
|
||||||
|
history: hist ?? [],
|
||||||
|
bookmarks: bk ?? [],
|
||||||
|
markers: mk ?? [],
|
||||||
|
readLog: rl ?? [],
|
||||||
|
readingStats: rs ?? null,
|
||||||
|
dailyReadCounts: dc ?? {},
|
||||||
|
libraryUpdates: lu ?? [],
|
||||||
|
lastLibraryRefresh: llr ?? 0,
|
||||||
|
acknowledgedUpdateIds: au ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku-store");
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
|
||||||
|
persistLibrary({
|
||||||
|
history: data.history ?? [],
|
||||||
|
bookmarks: data.bookmarks ?? [],
|
||||||
|
markers: data.markers ?? [],
|
||||||
|
readLog: data.readLog ?? [],
|
||||||
|
readingStats: data.readingStats ?? null,
|
||||||
|
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||||
|
}),
|
||||||
|
persistUpdates({
|
||||||
|
libraryUpdates: data.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
localStorage.removeItem("moku-store");
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeVersion: data.storeVersion ?? null,
|
||||||
|
settings: data.settings ?? null,
|
||||||
|
history: data.history ?? [],
|
||||||
|
bookmarks: data.bookmarks ?? [],
|
||||||
|
markers: data.markers ?? [],
|
||||||
|
readLog: data.readLog ?? [],
|
||||||
|
readingStats: data.readingStats ?? null,
|
||||||
|
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||||
|
libraryUpdates: data.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistSettings(data: { settings: any; storeVersion: number }) {
|
||||||
|
await Promise.all([
|
||||||
|
settingsStore.set("settings", data.settings),
|
||||||
|
settingsStore.set("storeVersion", data.storeVersion),
|
||||||
|
]);
|
||||||
|
await settingsStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistLibrary(data: {
|
||||||
|
history: any[];
|
||||||
|
bookmarks: any[];
|
||||||
|
markers: any[];
|
||||||
|
readLog: any[];
|
||||||
|
readingStats: any;
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
await Promise.all([
|
||||||
|
libraryStore.set("history", data.history),
|
||||||
|
libraryStore.set("bookmarks", data.bookmarks),
|
||||||
|
libraryStore.set("markers", data.markers),
|
||||||
|
libraryStore.set("readLog", data.readLog),
|
||||||
|
libraryStore.set("readingStats", data.readingStats),
|
||||||
|
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
|
||||||
|
]);
|
||||||
|
await libraryStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistUpdates(data: {
|
||||||
|
libraryUpdates: any[];
|
||||||
|
lastLibraryRefresh: number;
|
||||||
|
acknowledgedUpdateIds: number[];
|
||||||
|
}) {
|
||||||
|
await Promise.all([
|
||||||
|
updatesStore.set("libraryUpdates", data.libraryUpdates),
|
||||||
|
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
|
||||||
|
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
|
||||||
|
]);
|
||||||
|
await updatesStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupEntry { url: string; name: string; }
|
||||||
|
|
||||||
|
export async function loadBackups(): Promise<BackupEntry[]> {
|
||||||
|
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
||||||
|
if (fromStore) return fromStore;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku_backups");
|
||||||
|
if (!raw) return [];
|
||||||
|
const migrated: BackupEntry[] = JSON.parse(raw);
|
||||||
|
await persistBackups(migrated);
|
||||||
|
localStorage.removeItem("moku_backups");
|
||||||
|
return migrated;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||||
|
await backupsStore.set("backupList", list);
|
||||||
|
await backupsStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAuthSettings(): Promise<void> {
|
||||||
|
const current = await settingsStore.get<any>("settings") ?? {};
|
||||||
|
current.serverAuthMode = "NONE";
|
||||||
|
current.serverAuthUser = "";
|
||||||
|
current.serverAuthPass = "";
|
||||||
|
await settingsStore.set("settings", current);
|
||||||
|
await settingsStore.save();
|
||||||
|
localStorage.removeItem("moku-credential-vault");
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './idle';
|
export * from './idle';
|
||||||
export * from './zoom';
|
export * from './zoom';
|
||||||
|
export * from './touchscreen';
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
export interface LongPressOptions {
|
||||||
|
onLongPress: (e: PointerEvent) => void;
|
||||||
|
duration?: number;
|
||||||
|
moveThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
||||||
|
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
let fired = false;
|
||||||
|
|
||||||
|
function start(e: PointerEvent) {
|
||||||
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
|
startX = e.clientX; startY = e.clientY; fired = false;
|
||||||
|
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
|
||||||
|
}
|
||||||
|
function move(e: PointerEvent) {
|
||||||
|
if (!timer) return;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
|
||||||
|
}
|
||||||
|
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", start);
|
||||||
|
node.addEventListener("pointermove", move);
|
||||||
|
node.addEventListener("pointerup", cancel);
|
||||||
|
node.addEventListener("pointerleave", cancel);
|
||||||
|
node.addEventListener("pointercancel",cancel);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fired() { return fired; },
|
||||||
|
destroy() {
|
||||||
|
cancel();
|
||||||
|
node.removeEventListener("pointerdown", start);
|
||||||
|
node.removeEventListener("pointermove", move);
|
||||||
|
node.removeEventListener("pointerup", cancel);
|
||||||
|
node.removeEventListener("pointerleave", cancel);
|
||||||
|
node.removeEventListener("pointercancel",cancel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TapOptions {
|
||||||
|
onTap: (e: PointerEvent) => void;
|
||||||
|
onDoubleTap?: (e: PointerEvent) => void;
|
||||||
|
doubleTapGap?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tap(node: HTMLElement, opts: TapOptions) {
|
||||||
|
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
|
||||||
|
let lastTap = 0;
|
||||||
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
const SLOP = 8;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
|
||||||
|
function up(e: PointerEvent) {
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (onDoubleTap && now - lastTap < doubleTapGap) {
|
||||||
|
if (pending) { clearTimeout(pending); pending = null; }
|
||||||
|
onDoubleTap(e);
|
||||||
|
lastTap = 0;
|
||||||
|
} else {
|
||||||
|
lastTap = now;
|
||||||
|
if (onDoubleTap) {
|
||||||
|
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
|
||||||
|
} else {
|
||||||
|
onTap(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeOptions {
|
||||||
|
onSwipeLeft?: (e: PointerEvent) => void;
|
||||||
|
onSwipeRight?: (e: PointerEvent) => void;
|
||||||
|
onSwipeUp?: (e: PointerEvent) => void;
|
||||||
|
onSwipeDown?: (e: PointerEvent) => void;
|
||||||
|
threshold?: number;
|
||||||
|
lockAxis?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swipe(node: HTMLElement, opts: SwipeOptions) {
|
||||||
|
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
|
||||||
|
let startX = 0, startY = 0, active = false;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) {
|
||||||
|
if (e.pointerType === "mouse") return;
|
||||||
|
startX = e.clientX; startY = e.clientY; active = true;
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
function up(e: PointerEvent) {
|
||||||
|
if (!active) return; active = false;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
const ax = Math.abs(dx), ay = Math.abs(dy);
|
||||||
|
if (Math.max(ax, ay) < threshold) return;
|
||||||
|
if (lockAxis && ax > ay) {
|
||||||
|
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
|
||||||
|
} else if (lockAxis && ay >= ax) {
|
||||||
|
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
|
||||||
|
} else {
|
||||||
|
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
|
||||||
|
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cancel() { active = false; }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
node.addEventListener("pointercancel", cancel);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
node.removeEventListener("pointercancel", cancel);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchOptions {
|
||||||
|
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||||
|
onPinchEnd?: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchGestureOptions {
|
||||||
|
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||||
|
onPinchEnd?: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchGesture {
|
||||||
|
onPointerDown: (e: PointerEvent) => void;
|
||||||
|
onPointerMove: (e: PointerEvent) => void;
|
||||||
|
onPointerUp: (e: PointerEvent) => void;
|
||||||
|
isPinching: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
|
||||||
|
const { onPinch, onPinchEnd } = opts;
|
||||||
|
const pointers = new Map<number, PointerEvent>();
|
||||||
|
let initDist = 0;
|
||||||
|
|
||||||
|
function pdist(a: PointerEvent, b: PointerEvent) {
|
||||||
|
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
function pmid(a: PointerEvent, b: PointerEvent) {
|
||||||
|
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
pointers.set(e.pointerId, e);
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
initDist = pdist(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!pointers.has(e.pointerId)) return;
|
||||||
|
pointers.set(e.pointerId, e);
|
||||||
|
if (pointers.size !== 2 || initDist === 0) return;
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
onPinch(pdist(a, b) / initDist, pmid(a, b));
|
||||||
|
}
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (pointers.size === 2 && onPinchEnd) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
onPinchEnd(pdist(a, b) / initDist);
|
||||||
|
}
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
initDist = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
||||||
|
const gesture = createPinchGesture(opts);
|
||||||
|
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointermove", gesture.onPointerMove);
|
||||||
|
node.addEventListener("pointerup", gesture.onPointerUp);
|
||||||
|
node.addEventListener("pointercancel", gesture.onPointerUp);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointermove", gesture.onPointerMove);
|
||||||
|
node.removeEventListener("pointerup", gesture.onPointerUp);
|
||||||
|
node.removeEventListener("pointercancel", gesture.onPointerUp);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragScrollOptions {
|
||||||
|
direction?: "x" | "y" | "both";
|
||||||
|
onDragStart?: () => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
|
||||||
|
const { direction = "both", onDragStart, onDragEnd } = opts;
|
||||||
|
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) {
|
||||||
|
if (e.pointerType === "mouse") return;
|
||||||
|
active = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
scrollX = node.scrollLeft; scrollY = node.scrollTop;
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
onDragStart?.();
|
||||||
|
}
|
||||||
|
function move(e: PointerEvent) {
|
||||||
|
if (!active) return;
|
||||||
|
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
|
||||||
|
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
|
||||||
|
}
|
||||||
|
function up() { if (active) { active = false; onDragEnd?.(); } }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointermove", move);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
node.addEventListener("pointercancel", up);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointermove", move);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
node.removeEventListener("pointercancel", up);
|
||||||
|
}};
|
||||||
|
}
|
||||||
+69
-87
@@ -1,12 +1,8 @@
|
|||||||
import type { Manga, Source } from "@types";
|
import type { Manga, Source } from "@types";
|
||||||
import type { Settings } from "@types";
|
import type { Settings } from "@types";
|
||||||
|
|
||||||
// ── Class utility ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export { clsx as cn } from "clsx";
|
export { clsx as cn } from "clsx";
|
||||||
|
|
||||||
// ── Time / formatting ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function timeAgo(ts: number): string {
|
export function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
if (m < 1) return "Just now";
|
if (m < 1) return "Just now";
|
||||||
@@ -33,85 +29,95 @@ export function formatReadTime(m: number): string {
|
|||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NSFW filtering ────────────────────────────────────────────────────────────
|
const STRICT_TAGS: string[] = [
|
||||||
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
/**
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
* Default genre substrings used when no user-configured list is available.
|
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||||
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_NSFW_TAGS = [
|
|
||||||
"adult",
|
|
||||||
"mature",
|
|
||||||
"hentai",
|
|
||||||
"ecchi",
|
|
||||||
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
|
||||||
"pornograph", // catches "pornographic", "pornography"
|
|
||||||
"18+",
|
|
||||||
"smut",
|
|
||||||
"lemon",
|
|
||||||
"explicit",
|
|
||||||
"sexual violence",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const MODERATE_TAGS: string[] = [
|
||||||
* Returns true if the manga's genre list contains any of the given substrings.
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
*/
|
];
|
||||||
export function isNsfwManga(
|
|
||||||
manga: { genre?: string[] | null },
|
type ContentFilterSettings = Pick<
|
||||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
Settings,
|
||||||
): boolean {
|
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
|
||||||
return (manga.genre ?? []).some(g =>
|
>;
|
||||||
tags.some(sub => g.toLowerCase().trim().includes(sub))
|
|
||||||
);
|
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||||
|
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||||
|
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||||
|
if (!blockedTags.length) return false;
|
||||||
|
return genre.some(g => {
|
||||||
|
const norm = g.toLowerCase().trim();
|
||||||
|
return blockedTags.some(tag => {
|
||||||
|
const idx = norm.indexOf(tag);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||||
|
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||||
|
return before && after;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single authoritative NSFW gate used by all views.
|
|
||||||
* Returns true when the manga should be HIDDEN. Priority order:
|
|
||||||
* 1. Source in blockedSourceIds → always hidden, even when showNsfw is on.
|
|
||||||
* 2. showNsfw globally enabled → only blocked sources are hidden.
|
|
||||||
* 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply.
|
|
||||||
* 4. source.isNsfw flag → hidden.
|
|
||||||
* 5. Genre tag match → hidden.
|
|
||||||
*
|
|
||||||
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
|
||||||
*/
|
|
||||||
export function shouldHideNsfw(
|
export function shouldHideNsfw(
|
||||||
manga: Pick<Manga, "genre" | "source">,
|
manga: Pick<Manga, "genre" | "source">,
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: ContentFilterSettings,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
|
|
||||||
const srcId = manga.source?.id;
|
const srcId = manga.source?.id;
|
||||||
|
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||||
|
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||||
|
|
||||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
if (srcId && blocked.includes(srcId)) return true;
|
||||||
if (settings.showNsfw) return false;
|
|
||||||
|
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||||
|
|
||||||
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
|
||||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
|
||||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
|
||||||
*/
|
|
||||||
export function shouldHideSource(
|
export function shouldHideSource(
|
||||||
source: Pick<Source, "id" | "isNsfw">,
|
source: Pick<Source, "id" | "isNsfw">,
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: ContentFilterSettings,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
|
||||||
return !settings.showNsfw && source.isNsfw;
|
if (settings.sourceOverridesEnabled) {
|
||||||
|
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||||
|
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
return source.isNsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeSourcesByLang(
|
||||||
|
sources: Source[],
|
||||||
|
preferredLang: string,
|
||||||
|
settings: ContentFilterSettings,
|
||||||
|
applyHide = false,
|
||||||
|
): Source[] {
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.id === "0") continue;
|
||||||
|
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name. When multiple sources share a name,
|
|
||||||
* the preferred language wins; otherwise falls back to alphabetical by lang.
|
|
||||||
* The local source (id "0") is always excluded.
|
|
||||||
*/
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
const byName = new Map<string, Source[]>();
|
const byName = new Map<string, Source[]>();
|
||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
@@ -127,9 +133,6 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
return picked;
|
return picked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
|
|
||||||
export function normalizeTitle(title: string): string {
|
export function normalizeTitle(title: string): string {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -140,39 +143,21 @@ export function normalizeTitle(title: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strips all non-alphanumeric chars and collapses whitespace. */
|
|
||||||
function norm(s: string): string {
|
function norm(s: string): string {
|
||||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* First 200 normalized chars of a description — reliable cross-source fingerprint.
|
|
||||||
* Returns null if too short (< 60 chars) to be a trustworthy signal.
|
|
||||||
*/
|
|
||||||
function descFingerprint(desc: string | null | undefined): string | null {
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
const n = norm(desc);
|
const n = norm(desc);
|
||||||
return n.length >= 60 ? n.slice(0, 200) : null;
|
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalized author + artist concatenation for tie-breaking.
|
|
||||||
* Returns null if no author info available.
|
|
||||||
*/
|
|
||||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||||
return parts.length ? parts.sort().join("|") : null;
|
return parts.length ? parts.sort().join("|") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates manga across sources using title, description, and author signals,
|
|
||||||
* plus explicit user-defined links (settings.mangaLinks).
|
|
||||||
*
|
|
||||||
* When two entries match, the better one is kept:
|
|
||||||
* - Library membership wins over non-library.
|
|
||||||
* - Otherwise higher downloadCount wins.
|
|
||||||
* - Otherwise first occurrence wins.
|
|
||||||
*/
|
|
||||||
export function dedupeMangaByTitle<T extends {
|
export function dedupeMangaByTitle<T extends {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -228,9 +213,6 @@ export function dedupeMangaByTitle<T extends {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lossless deduplication by ID only. Preserves first occurrence.
|
|
||||||
*/
|
|
||||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
|
|||||||
@@ -32,6 +32,4 @@
|
|||||||
|
|
||||||
--dot-active: var(--accent);
|
--dot-active: var(--accent);
|
||||||
--dot-inactive: var(--text-faint);
|
--dot-inactive: var(--text-faint);
|
||||||
|
|
||||||
--bg-image: none;
|
|
||||||
}
|
}
|
||||||
@@ -9,4 +9,5 @@
|
|||||||
--sp-10: 40px;
|
--sp-10: 40px;
|
||||||
|
|
||||||
--sidebar-width: 52px;
|
--sidebar-width: 52px;
|
||||||
|
--titlebar-height: 36px;
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
import { runConcurrent } from "@core/async/batchRequests";
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
|
import { preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { Manga, Source } from "@types";
|
import type { Manga, Source } from "@types";
|
||||||
|
import type { CachedManga } from "@features/discover/lib/searchFilter";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
@@ -16,12 +18,14 @@
|
|||||||
pendingPrefill: string;
|
pendingPrefill: string;
|
||||||
popularResults: (Manga & { _priority: number })[];
|
popularResults: (Manga & { _priority: number })[];
|
||||||
popularLoading: boolean;
|
popularLoading: boolean;
|
||||||
|
sourceCache: Map<number, CachedManga>;
|
||||||
onPrefillConsumed: () => void;
|
onPrefillConsumed: () => void;
|
||||||
onPreview: (m: Manga) => void;
|
onPreview: (m: Manga) => void;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||||
pendingPrefill, popularResults, popularLoading,
|
pendingPrefill, popularResults, popularLoading,
|
||||||
|
sourceCache,
|
||||||
onPrefillConsumed, onPreview,
|
onPrefillConsumed, onPreview,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@
|
|||||||
let filtered = allSources;
|
let filtered = allSources;
|
||||||
if (kw_selectedLangs.size > 0)
|
if (kw_selectedLangs.size > 0)
|
||||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
if (!store.settings.showNsfw)
|
if (store.settings.contentLevel !== "unrestricted")
|
||||||
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,10 @@
|
|||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
|
preloadBlobUrls(
|
||||||
|
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
|
||||||
|
12,
|
||||||
|
);
|
||||||
const next = [...kw_results];
|
const next = [...kw_results];
|
||||||
next[idx] = { ...next[idx], mangas, loading: false };
|
next[idx] = { ...next[idx], mangas, loading: false };
|
||||||
kw_results = next;
|
kw_results = next;
|
||||||
@@ -276,7 +284,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
@@ -308,12 +315,12 @@
|
|||||||
|
|
||||||
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||||
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); contain: layout style; }
|
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||||
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
|
||||||
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||||
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||||
|
|
||||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
||||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
import { shouldHideNsfw } from "@core/util";
|
import { shouldHideNsfw } from "@core/util";
|
||||||
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
|
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||||
import {
|
import {
|
||||||
toCachedManga,
|
toCachedManga,
|
||||||
type CachedManga,
|
type CachedManga,
|
||||||
@@ -287,6 +287,9 @@
|
|||||||
{pendingPrefill}
|
{pendingPrefill}
|
||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
|
{sourceCache}
|
||||||
|
query={store.searchQuery}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||||
onPreview={setPreviewManga}
|
onPreview={setPreviewManga}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export interface CachedManga {
|
|||||||
genreEnriched: boolean;
|
genreEnriched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const COMMON_GENRES = [
|
export const COMMON_GENRES = [
|
||||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||||
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
||||||
@@ -66,7 +65,6 @@ export const MANGA_STATUSES: { value: string; label: string }[] = [
|
|||||||
{ value: "UNKNOWN", label: "Unknown" },
|
{ value: "UNKNOWN", label: "Unknown" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export function buildTagFilter(
|
export function buildTagFilter(
|
||||||
tags: string[],
|
tags: string[],
|
||||||
mode: TagMode,
|
mode: TagMode,
|
||||||
@@ -90,13 +88,12 @@ export function buildTagFilter(
|
|||||||
return { and: [genrePart, statusPart] };
|
return { and: [genrePart, statusPart] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function filterSourceCache(
|
export function filterSourceCache(
|
||||||
sourceCache: Map<number, CachedManga>,
|
sourceCache: Map<number, CachedManga>,
|
||||||
tags: string[],
|
tags: string[],
|
||||||
mode: TagMode,
|
mode: TagMode,
|
||||||
statuses: string[],
|
statuses: string[],
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
): CachedManga[] {
|
): CachedManga[] {
|
||||||
return [...sourceCache.values()].filter((m) => {
|
return [...sourceCache.values()].filter((m) => {
|
||||||
if (shouldHideNsfw(m as any, settings)) return false;
|
if (shouldHideNsfw(m as any, settings)) return false;
|
||||||
@@ -118,7 +115,6 @@ export function filterSourceCache(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function toCachedManga(
|
export function toCachedManga(
|
||||||
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||||
srcId: string,
|
srcId: string,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { DownloadQueueItem } from "@types/index";
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
import { pageProgress } from "../lib/downloadQueue";
|
import { pageProgress } from "../lib/downloadQueue";
|
||||||
|
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
const prog = $derived(pageProgress(item.progress, pages));
|
const prog = $derived(pageProgress(item.progress, pages));
|
||||||
const isError = $derived(item.state === "ERROR");
|
const isError = $derived(item.state === "ERROR");
|
||||||
const pct = $derived(Math.round(item.progress * 100));
|
const pct = $derived(Math.round(item.progress * 100));
|
||||||
|
|
||||||
|
function rowLongPress(node: HTMLElement) {
|
||||||
|
return longPress(node, {
|
||||||
|
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -32,7 +39,12 @@
|
|||||||
class:row-error={isError}
|
class:row-error={isError}
|
||||||
class:row-selected={isSelected}
|
class:row-selected={isSelected}
|
||||||
class:row-removing={isRemoving}
|
class:row-removing={isRemoving}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
tabindex="0"
|
||||||
|
use:rowLongPress
|
||||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||||
|
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
|
||||||
>
|
>
|
||||||
{#if manga?.thumbnailUrl}
|
{#if manga?.thumbnailUrl}
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
|
|||||||
@@ -23,8 +23,28 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="empty">
|
<div class="list">
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
{#each Array(5) as _, i (i)}
|
||||||
|
<div class="sk-row">
|
||||||
|
<div class="sk-thumb skeleton"></div>
|
||||||
|
|
||||||
|
<div class="sk-info">
|
||||||
|
<div class="skeleton sk-title"></div>
|
||||||
|
<div class="skeleton sk-chapter"></div>
|
||||||
|
<div class="sk-progress-row">
|
||||||
|
<div class="skeleton sk-bar"></div>
|
||||||
|
<div class="skeleton sk-pages"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-right">
|
||||||
|
<div class="skeleton sk-state"></div>
|
||||||
|
<div class="sk-actions">
|
||||||
|
<div class="skeleton sk-btn"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if queue.length === 0}
|
{:else if queue.length === 0}
|
||||||
<div class="empty">Queue is empty.</div>
|
<div class="empty">Queue is empty.</div>
|
||||||
@@ -49,4 +69,30 @@
|
|||||||
<style>
|
<style>
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||||
|
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
|
||||||
|
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
|
||||||
|
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
|
||||||
|
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
|
||||||
|
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
|
||||||
|
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.sk-bar { flex: 1; height: 2px; }
|
||||||
|
.sk-pages { width: 28px; height: 9px; }
|
||||||
|
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
.sk-state { width: 54px; height: 9px; }
|
||||||
|
.sk-actions { display: flex; gap: 2px; }
|
||||||
|
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||||
</style>
|
</style>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bar-wrap">
|
<div class="bar-wrap">
|
||||||
<div class="status-bar" onclick={handleClickOff} role="presentation">
|
<div class="status-bar" role="none">
|
||||||
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||||
<span class="status-text">
|
<span class="status-text">
|
||||||
{downloadStore.togglingPlay
|
{downloadStore.togglingPlay
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
||||||
<ArrowLineUp size={12} weight="bold" />
|
<ArrowLineUp size={12} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
<div class="move-step" onclick={(e) => e.stopPropagation()} role="presentation">
|
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
|
||||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
||||||
<CaretUp size={12} weight="bold" />
|
<CaretUp size={12} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content" onclick={handleClickOff}>
|
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
|
||||||
<DownloadQueue
|
<DownloadQueue
|
||||||
queue={downloadStore.queue}
|
queue={downloadStore.queue}
|
||||||
loading={downloadStore.loading}
|
loading={downloadStore.loading}
|
||||||
@@ -204,9 +204,6 @@
|
|||||||
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.status-bar { cursor: default; }
|
.status-bar { cursor: default; }
|
||||||
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); white-space: nowrap; }
|
|
||||||
.sel-text-btn:hover { color: var(--text-primary); }
|
|
||||||
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
@@ -214,8 +211,8 @@
|
|||||||
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||||
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
|
|||||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AVG_BYTES_PER_PAGE = 1_500_000;
|
||||||
|
|
||||||
|
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of queue) {
|
||||||
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
|
const remaining = pages - Math.round(item.progress * pages);
|
||||||
|
total += remaining * AVG_BYTES_PER_PAGE;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import {
|
|||||||
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
||||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
||||||
} from "@api/mutations";
|
} from "@api/mutations";
|
||||||
import { addToast, setActiveDownloads } from "@store/state.svelte";
|
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
|
||||||
|
import { boot } from "@store/boot.svelte";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||||
import {
|
import {
|
||||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||||
isRunning, getErrored, calcSpeed, estimateEta,
|
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||||
type SpeedSample,
|
type SpeedSample,
|
||||||
} from "../lib/downloadQueue";
|
} from "../lib/downloadQueue";
|
||||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
class DownloadStore {
|
class DownloadStore {
|
||||||
status: DownloadStatus | null = $state(null);
|
status: DownloadStatus | null = $state(null);
|
||||||
@@ -24,9 +26,12 @@ class DownloadStore {
|
|||||||
batchWorking = $state(false);
|
batchWorking = $state(false);
|
||||||
pagesPerSec: number | null = $state(null);
|
pagesPerSec: number | null = $state(null);
|
||||||
eta: number | null = $state(null);
|
eta: number | null = $state(null);
|
||||||
|
storageWarning: boolean = $state(false);
|
||||||
|
|
||||||
toastsEnabled = $state(true);
|
private freeBytes: number | null = null;
|
||||||
autoRetryEnabled = $state(false);
|
|
||||||
|
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||||
|
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||||
|
|
||||||
private lastSample: SpeedSample | null = null;
|
private lastSample: SpeedSample | null = null;
|
||||||
private prevQueue: DownloadQueueItem[] = [];
|
private prevQueue: DownloadQueueItem[] = [];
|
||||||
@@ -38,18 +43,19 @@ class DownloadStore {
|
|||||||
get hasErrored() { return this.erroredIds.size > 0; }
|
get hasErrored() { return this.erroredIds.size > 0; }
|
||||||
|
|
||||||
toggleToasts() {
|
toggleToasts() {
|
||||||
this.toastsEnabled = !this.toastsEnabled;
|
const next = !this.toastsEnabled;
|
||||||
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
updateSettings({ downloadToastsEnabled: next });
|
||||||
|
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAutoRetry() {
|
toggleAutoRetry() {
|
||||||
if (this.autoRetryEnabled) {
|
if (this.autoRetryEnabled) {
|
||||||
this.autoRetryHnd?.stop();
|
this.autoRetryHnd?.stop();
|
||||||
this.autoRetryHnd = null;
|
this.autoRetryHnd = null;
|
||||||
this.autoRetryEnabled = false;
|
updateSettings({ downloadAutoRetry: false });
|
||||||
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
||||||
} else {
|
} else {
|
||||||
this.autoRetryEnabled = true;
|
updateSettings({ downloadAutoRetry: true });
|
||||||
this.autoRetryHnd = startAutoRetry(
|
this.autoRetryHnd = startAutoRetry(
|
||||||
() => this.queue,
|
() => this.queue,
|
||||||
() => this.isRunning,
|
() => this.isRunning,
|
||||||
@@ -80,6 +86,52 @@ class DownloadStore {
|
|||||||
this.status = ds;
|
this.status = ds;
|
||||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||||
this.updateSpeed(ds);
|
this.updateSpeed(ds);
|
||||||
|
this.fetchFreeBytes(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFreeBytes(ds: DownloadStatus) {
|
||||||
|
const path = store.settings.serverDownloadsPath ?? "";
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
|
||||||
|
this.freeBytes = info.free_bytes;
|
||||||
|
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmStorageOverrun(): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
|
||||||
|
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||||
|
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
|
||||||
|
The download queue is estimated to exceed 95% of your available storage. Download anyway?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
|
||||||
|
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
|
||||||
|
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(panel);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
function finish(result: boolean) { backdrop.remove(); resolve(result); }
|
||||||
|
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
|
||||||
|
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
|
||||||
|
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
|
||||||
|
if (this.freeBytes === null) return true;
|
||||||
|
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
|
||||||
|
return this.confirmStorageOverrun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSpeed(ds: DownloadStatus) {
|
private updateSpeed(ds: DownloadStatus) {
|
||||||
@@ -104,6 +156,7 @@ class DownloadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async poll() {
|
async poll() {
|
||||||
|
if (boot.sessionExpired) return;
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
.then((d) => this.applyStatus(d.downloadStatus))
|
.then((d) => this.applyStatus(d.downloadStatus))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -169,11 +222,21 @@ class DownloadStore {
|
|||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enqueue(chapterId: number): Promise<boolean> {
|
||||||
|
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
|
||||||
|
if (!(await this.guardStorage(projected))) return false;
|
||||||
|
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async retryOne(chapterId: number) {
|
async retryOne(chapterId: number) {
|
||||||
if (this.dequeueing.has(chapterId)) return;
|
if (this.dequeueing.has(chapterId)) return;
|
||||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||||
|
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
@@ -186,6 +249,8 @@ class DownloadStore {
|
|||||||
const ids = [...this.erroredIds];
|
const ids = [...this.erroredIds];
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
|
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
this.poll();
|
this.poll();
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
@@ -201,6 +266,8 @@ class DownloadStore {
|
|||||||
try {
|
try {
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
|
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte";
|
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { Extension } from "@types/index";
|
import type { Extension } from "@types/index";
|
||||||
|
|
||||||
|
type SourceEntry = { id: string; displayName: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
base: string;
|
base: string;
|
||||||
primary: Extension;
|
primary: Extension;
|
||||||
@@ -10,17 +12,27 @@
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
working: Set<string>;
|
working: Set<string>;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
|
sources: SourceEntry[];
|
||||||
|
libraryCount: number;
|
||||||
onToggle: (base: string) => void;
|
onToggle: (base: string) => void;
|
||||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||||
|
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { base, primary, variants, expanded, working, anims, onToggle, onMutate }: Props = $props();
|
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
|
||||||
|
|
||||||
|
const clickable = $derived(primary.isInstalled);
|
||||||
|
|
||||||
const hasVariants = $derived(variants.length > 0);
|
const hasVariants = $derived(variants.length > 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="row">
|
<svelte:element
|
||||||
|
this={clickable ? "button" : "div"}
|
||||||
|
class="row"
|
||||||
|
class:row-clickable={clickable}
|
||||||
|
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
|
||||||
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
src={primary.iconUrl}
|
src={primary.iconUrl}
|
||||||
alt={primary.name}
|
alt={primary.name}
|
||||||
@@ -31,6 +43,13 @@
|
|||||||
<span class="name">{base}</span>
|
<span class="name">{base}</span>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
||||||
|
{#if primary.isInstalled}
|
||||||
|
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
|
||||||
|
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
|
||||||
|
{libraryCount > 0 ? libraryCount : 0}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
v{primary.versionName}
|
v{primary.versionName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,22 +58,24 @@
|
|||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
{:else if primary.hasUpdate}
|
{:else if primary.hasUpdate}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "update")}>Update</button>
|
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
|
||||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if primary.isInstalled}
|
{:else if primary.isInstalled}
|
||||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
<div class="row-actions">
|
||||||
|
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasVariants}
|
{#if hasVariants}
|
||||||
<button class="expand-btn" onclick={() => onToggle(base)} title="{variants.length + 1} languages">
|
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
|
||||||
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
<span class="expand-count">{variants.length + 1}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</svelte:element>
|
||||||
|
|
||||||
{#if expanded && hasVariants}
|
{#if expanded && hasVariants}
|
||||||
<div class="variants" class:variants-anim={anims}>
|
<div class="variants" class:variants-anim={anims}>
|
||||||
@@ -83,15 +104,18 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.group { display: flex; flex-direction: column; }
|
.group { display: flex; flex-direction: column; }
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.row-clickable { cursor: pointer; }
|
||||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||||
|
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
|
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||||
.action-btn:hover { filter: brightness(1.1); }
|
.action-btn:hover { filter: brightness(1.1); }
|
||||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
@@ -106,5 +130,5 @@
|
|||||||
.variant-row:hover { background: var(--bg-raised); }
|
.variant-row:hover { background: var(--bg-raised); }
|
||||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
.variant-actions { flex-shrink: 0; }
|
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
|
||||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,6 +40,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each FILTERS as f}
|
{#each FILTERS as f}
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
||||||
|
{#if f.id === "installed"}
|
||||||
|
<CheckCircle size={11} weight="bold" />
|
||||||
|
{:else if f.id === "available"}
|
||||||
|
<Globe size={11} weight="bold" />
|
||||||
|
{:else if f.id === "updates"}
|
||||||
|
<ArrowCircleUp size={11} weight="bold" />
|
||||||
|
{:else if f.id === "all"}
|
||||||
|
<Rows size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -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 { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { store, addToast } from "@store/state.svelte";
|
import { store, addToast } from "@store/state.svelte";
|
||||||
import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries";
|
import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
|
||||||
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
||||||
import type { Extension } from "@types/index";
|
import type { Extension } from "@types/index";
|
||||||
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||||
|
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
|
||||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||||
import ExtensionCard from "./ExtensionCard.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 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);
|
||||||
|
|
||||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let tabIndicator = $state({ left: 0, width: 0 });
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
|
|
||||||
@@ -33,6 +40,15 @@
|
|||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
let panel = $state<Panel>(null);
|
let panel = $state<Panel>(null);
|
||||||
|
|
||||||
|
type SourceEntry = { id: string; displayName: string };
|
||||||
|
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
|
||||||
|
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
|
||||||
|
|
||||||
|
let settingsTarget = $state<SettingsTarget | null>(null);
|
||||||
|
let libraryTarget = $state<LibraryTarget | null>(null);
|
||||||
|
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
|
||||||
|
let libCountByPkg = $state<Record<string, number>>({});
|
||||||
|
|
||||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
let externalUrl = $state("");
|
let externalUrl = $state("");
|
||||||
@@ -47,8 +63,25 @@
|
|||||||
let savingRepos = $state(false);
|
let savingRepos = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error);
|
const [extData, srcData, libData] = await Promise.all([
|
||||||
if (d) extensions = d.extensions.nodes;
|
gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
|
||||||
|
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
|
||||||
|
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
|
||||||
|
]);
|
||||||
|
if (extData) extensions = extData.extensions.nodes;
|
||||||
|
if (srcData) {
|
||||||
|
const map: Record<string, SourceEntry[]> = {};
|
||||||
|
for (const s of srcData.sources.nodes) {
|
||||||
|
if (!s.isConfigurable || !s.extension?.pkgName) continue;
|
||||||
|
const pkg = s.extension.pkgName;
|
||||||
|
if (!map[pkg]) map[pkg] = [];
|
||||||
|
map[pkg].push({ id: s.id, displayName: s.displayName });
|
||||||
|
}
|
||||||
|
sourcesByPkg = map;
|
||||||
|
}
|
||||||
|
if (libData && srcData) {
|
||||||
|
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLocalManga() {
|
async function loadLocalManga() {
|
||||||
@@ -213,6 +246,17 @@
|
|||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#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">
|
<div class="root anim-fade-in">
|
||||||
<ExtensionFilters
|
<ExtensionFilters
|
||||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||||
@@ -307,9 +351,12 @@
|
|||||||
{#each groups as { base, primary, variants }}
|
{#each groups as { base, primary, variants }}
|
||||||
<ExtensionCard
|
<ExtensionCard
|
||||||
{base} {primary} {variants} {working} {anims}
|
{base} {primary} {variants} {working} {anims}
|
||||||
|
sources={sourcesByPkg[primary.pkgName] ?? []}
|
||||||
|
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
|
||||||
expanded={expanded.has(base)}
|
expanded={expanded.has(base)}
|
||||||
onToggle={toggleExpand}
|
onToggle={toggleExpand}
|
||||||
onMutate={mutate}
|
onMutate={mutate}
|
||||||
|
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{#if !showLocal && groups.length === 0}
|
{#if !showLocal && groups.length === 0}
|
||||||
@@ -318,13 +365,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if settingsTarget}
|
||||||
|
<ExtensionSettingsPanel
|
||||||
|
extensionName={settingsTarget.extensionName}
|
||||||
|
iconUrl={settingsTarget.iconUrl}
|
||||||
|
sources={settingsTarget.sources}
|
||||||
|
onClose={() => settingsTarget = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
||||||
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||||
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface LibraryManga {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
unreadCount: number;
|
||||||
|
downloadCount: number;
|
||||||
|
source: { id: string; displayName: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceLibrary {
|
||||||
|
sourceId: string;
|
||||||
|
displayName: string;
|
||||||
|
manga: LibraryManga[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceNode = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
isConfigurable: boolean;
|
||||||
|
extension: { pkgName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function libraryByExtension(
|
||||||
|
libraryManga: LibraryManga[],
|
||||||
|
sources: SourceNode[],
|
||||||
|
pkgName: string,
|
||||||
|
): SourceLibrary[] {
|
||||||
|
const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName);
|
||||||
|
const sourceIds = new Set(pkgSources.map(s => s.id));
|
||||||
|
|
||||||
|
const bySource = new Map<string, LibraryManga[]>();
|
||||||
|
for (const src of pkgSources) bySource.set(src.id, []);
|
||||||
|
for (const m of libraryManga) {
|
||||||
|
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkgSources
|
||||||
|
.map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! }))
|
||||||
|
.filter(g => g.manga.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryCountByPkg(
|
||||||
|
libraryManga: LibraryManga[],
|
||||||
|
sources: SourceNode[],
|
||||||
|
): Record<string, number> {
|
||||||
|
const sourceIdToPkg = new Map<string, string>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
|
||||||
|
}
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const m of libraryManga) {
|
||||||
|
const pkg = sourceIdToPkg.get(m.source.id);
|
||||||
|
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -2,19 +2,26 @@
|
|||||||
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
|
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { HistoryEntry } from "@store/state.svelte";
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
import { timeAgo } from "../lib/homeHelpers";
|
import { timeAgo } from "../lib/homeHelpers";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
entries,
|
entries,
|
||||||
|
libraryManga,
|
||||||
onresume,
|
onresume,
|
||||||
onviewhistory,
|
onviewhistory,
|
||||||
onopenlibrary,
|
onopenlibrary,
|
||||||
}: {
|
}: {
|
||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
|
libraryManga: Manga[];
|
||||||
onresume: (entry: HistoryEntry) => void;
|
onresume: (entry: HistoryEntry) => void;
|
||||||
onviewhistory: () => void;
|
onviewhistory: () => void;
|
||||||
onopenlibrary: () => void;
|
onopenlibrary: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function thumbFor(entry: HistoryEntry): string {
|
||||||
|
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -31,7 +38,7 @@
|
|||||||
{#if entries.length > 0}
|
{#if entries.length > 0}
|
||||||
{#each entries as entry (entry.chapterId)}
|
{#each entries as entry (entry.chapterId)}
|
||||||
<button class="row" onclick={() => onresume(entry)}>
|
<button class="row" onclick={() => onresume(entry)}>
|
||||||
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" />
|
<Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
|
||||||
<div class="row-info">
|
<div class="row-info">
|
||||||
<span class="row-title">{entry.mangaTitle}</span>
|
<span class="row-title">{entry.mangaTitle}</span>
|
||||||
<span class="row-sub">
|
<span class="row-sub">
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
let wrapEl: HTMLElement;
|
let wrapEl: HTMLElement;
|
||||||
let cellSize = $state(12);
|
let cellSize = $state(12);
|
||||||
let numWeeks = $state(26);
|
let numWeeks = $state(26);
|
||||||
@@ -55,7 +59,7 @@
|
|||||||
const visibleWeeks = $derived((() => {
|
const visibleWeeks = $derived((() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const todayStr = today.toISOString().slice(0, 10);
|
const todayStr = localDateStr(today);
|
||||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||||
const weekEnd = new Date(today);
|
const weekEnd = new Date(today);
|
||||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
for (let di = 0; di < 7; di++) {
|
for (let di = 0; di < 7; di++) {
|
||||||
const d = new Date(weekEnd);
|
const d = new Date(weekEnd);
|
||||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||||
const dateStr = d.toISOString().slice(0, 10);
|
const dateStr = localDateStr(d);
|
||||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||||
}
|
}
|
||||||
weeks.push(week);
|
weeks.push(week);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
heroEntry,
|
heroEntry,
|
||||||
heroMangaId,
|
heroMangaId,
|
||||||
heroChapters,
|
heroChapters,
|
||||||
|
heroNewChapter,
|
||||||
loadingHeroChapters,
|
loadingHeroChapters,
|
||||||
resuming,
|
resuming,
|
||||||
onresume,
|
onresume,
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
heroEntry: HistoryEntry | null;
|
heroEntry: HistoryEntry | null;
|
||||||
heroMangaId: number | null;
|
heroMangaId: number | null;
|
||||||
heroChapters: Chapter[];
|
heroChapters: Chapter[];
|
||||||
|
heroNewChapter: Chapter | null;
|
||||||
loadingHeroChapters: boolean;
|
loadingHeroChapters: boolean;
|
||||||
resuming: boolean;
|
resuming: boolean;
|
||||||
onresume: () => void;
|
onresume: () => void;
|
||||||
@@ -102,6 +104,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if heroNewChapter && !heroNewChapter.isRead}
|
||||||
|
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
|
||||||
|
{/if}
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
||||||
<button
|
<button
|
||||||
class="hero-tag hero-tag-genre"
|
class="hero-tag hero-tag-genre"
|
||||||
@@ -326,6 +331,7 @@
|
|||||||
}
|
}
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
|
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
|
||||||
|
.hero-tag-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
|
||||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
|
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||||
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
|
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "@api/client";
|
import { gql, resolveImageUrl } from "@api/client";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
|
||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
@@ -88,31 +87,33 @@
|
|||||||
let activeIdx = $state(0);
|
let activeIdx = $state(0);
|
||||||
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
||||||
const heroThumbSrc = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
|
|
||||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
|
|
||||||
);
|
|
||||||
let heroThumb = $state("");
|
|
||||||
$effect(() => {
|
|
||||||
const path = heroThumbSrc;
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (!path) { heroThumb = ""; return; }
|
|
||||||
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
|
|
||||||
getBlobUrl(thumbUrl(path))
|
|
||||||
.then(url => { heroThumb = url; })
|
|
||||||
.catch(() => { heroThumb = ""; });
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroTitle = $derived(
|
|
||||||
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
|
|
||||||
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
|
|
||||||
);
|
|
||||||
const heroManga = $derived(
|
const heroManga = $derived(
|
||||||
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
activeSlot?.kind === "pinned" ? activeSlot.manga :
|
||||||
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
|
||||||
);
|
);
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null);
|
||||||
|
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? "");
|
||||||
|
|
||||||
|
const heroThumbSrc = $derived(
|
||||||
|
heroManga?.thumbnailUrl
|
||||||
|
?? (activeSlot?.kind === "continue" ? activeSlot.entry?.thumbnailUrl : undefined)
|
||||||
|
?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
let heroThumb = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const path = heroThumbSrc;
|
||||||
|
if (!path) { heroThumb = ""; return; }
|
||||||
|
resolveImageUrl(path)
|
||||||
|
.then(url => { heroThumb = url; })
|
||||||
|
.catch(() => { heroThumb = ""; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroNewChapter = $derived(
|
||||||
|
heroManga ? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
@@ -155,6 +156,10 @@
|
|||||||
|
|
||||||
let resuming = $state(false);
|
let resuming = $state(false);
|
||||||
|
|
||||||
|
function liveMangaStub(): Manga {
|
||||||
|
return heroManga ?? { id: heroMangaId!, title: heroTitle, thumbnailUrl: heroThumbSrc } as any;
|
||||||
|
}
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
async function openChapter(chapter: Chapter) {
|
||||||
if (!heroMangaId) return;
|
if (!heroMangaId) return;
|
||||||
resuming = true;
|
resuming = true;
|
||||||
@@ -165,13 +170,12 @@
|
|||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
if (all.length) {
|
if (all.length) {
|
||||||
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
|
store.activeManga = liveMangaStub();
|
||||||
store.activeManga = manga;
|
|
||||||
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
|
||||||
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
const target = list.find(c => c.id === chapter.id) ?? list[0];
|
||||||
if (target) openReader(target, list);
|
if (target) openReader(target, list);
|
||||||
}
|
}
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = liveMangaStub(); }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,24 +191,24 @@
|
|||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
|
||||||
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
|
||||||
if (ch) {
|
if (ch) {
|
||||||
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
store.activeManga = liveMangaStub();
|
||||||
openReader(ch, list);
|
openReader(ch, list);
|
||||||
}
|
}
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = liveMangaStub(); }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
async function resumeEntry(entry: HistoryEntry) {
|
||||||
|
const liveManga = libraryManga.find(m => m.id === entry.mangaId);
|
||||||
|
const stub = liveManga ?? { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: liveManga?.thumbnailUrl ?? entry.thumbnailUrl } as any;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
||||||
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
|
||||||
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
|
||||||
if (ch) {
|
store.activeManga = stub;
|
||||||
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
if (ch) openReader(ch, list);
|
||||||
openReader(ch, list);
|
} catch { store.activeManga = stub; }
|
||||||
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
@@ -234,6 +238,7 @@
|
|||||||
{heroEntry}
|
{heroEntry}
|
||||||
{heroMangaId}
|
{heroMangaId}
|
||||||
{heroChapters}
|
{heroChapters}
|
||||||
|
{heroNewChapter}
|
||||||
{loadingHeroChapters}
|
{loadingHeroChapters}
|
||||||
{resuming}
|
{resuming}
|
||||||
onresume={resumeActive}
|
onresume={resumeActive}
|
||||||
@@ -252,6 +257,7 @@
|
|||||||
<div class="mid-left">
|
<div class="mid-left">
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
entries={recentHistory}
|
entries={recentHistory}
|
||||||
|
{libraryManga}
|
||||||
onresume={resumeEntry}
|
onresume={resumeEntry}
|
||||||
onviewhistory={() => setNavPage("history")}
|
onviewhistory={() => setNavPage("history")}
|
||||||
onopenlibrary={() => setNavPage("library")}
|
onopenlibrary={() => setNavPage("library")}
|
||||||
@@ -328,7 +334,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* suppress ActivityFeed's own border-top — mid-row provides it */
|
|
||||||
.mid-left :global(.section) { border-top: none; }
|
.mid-left :global(.section) { border-top: none; }
|
||||||
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
.mid-right {
|
.mid-right {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export interface RecommendedManga {
|
|||||||
|
|
||||||
const TOP_GENRES = 6;
|
const TOP_GENRES = 6;
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
const MAX_PAGES = 5;
|
const MAX_PAGES = 10;
|
||||||
|
const TARGET_PER_GENRE = 20;
|
||||||
|
const EXCLUDED_STATUSES = ["CANCELLED", "ABANDONED"];
|
||||||
|
|
||||||
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
|
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
|
||||||
const byId = new Map(libraryManga.map(m => [m.id, m]));
|
const byId = new Map(libraryManga.map(m => [m.id, m]));
|
||||||
@@ -36,7 +38,11 @@ export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): strin
|
|||||||
|
|
||||||
type Result = { mangas: { nodes: Manga[] } };
|
type Result = { mangas: { nodes: Manga[] } };
|
||||||
|
|
||||||
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> {
|
async function fetchGenrePages(
|
||||||
|
genre: string,
|
||||||
|
globalSeen: Set<number>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Manga[]> {
|
||||||
const filter = {
|
const filter = {
|
||||||
and: [
|
and: [
|
||||||
buildTagFilter([genre], "OR", []),
|
buildTagFilter([genre], "OR", []),
|
||||||
@@ -44,23 +50,33 @@ async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Man
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const pages = await Promise.all(
|
const localSeen = new Set<number>();
|
||||||
Array.from({ length: MAX_PAGES }, (_, i) =>
|
|
||||||
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
|
|
||||||
.then(d => d.mangas.nodes)
|
|
||||||
.catch(() => [] as Manga[])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const nodes: Manga[] = [];
|
const nodes: Manga[] = [];
|
||||||
for (const page of pages) {
|
|
||||||
if (!page.length) break;
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
for (const m of page) {
|
if (signal?.aborted) break;
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
|
|
||||||
|
let batch: Manga[];
|
||||||
|
try {
|
||||||
|
const d = await gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: page * PAGE_SIZE }, signal);
|
||||||
|
batch = d.mangas.nodes;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (page.length < PAGE_SIZE) break;
|
|
||||||
|
if (!batch.length) break;
|
||||||
|
|
||||||
|
for (const m of batch) {
|
||||||
|
if (localSeen.has(m.id) || globalSeen.has(m.id)) continue;
|
||||||
|
if (EXCLUDED_STATUSES.includes(m.status ?? "")) continue;
|
||||||
|
localSeen.add(m.id);
|
||||||
|
nodes.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodes.length >= TARGET_PER_GENRE) break;
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +90,14 @@ export async function fetchRecommendations(
|
|||||||
const genres = topGenres(history, libraryManga);
|
const genres = topGenres(history, libraryManga);
|
||||||
if (!genres.length) return [];
|
if (!genres.length) return [];
|
||||||
|
|
||||||
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal)));
|
const globalSeen = new Set<number>();
|
||||||
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const merged: Manga[] = [];
|
const merged: Manga[] = [];
|
||||||
for (const page of perGenre) {
|
|
||||||
for (const m of page) {
|
for (const genre of genres) {
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
const results = await fetchGenrePages(genre, globalSeen, signal);
|
||||||
|
for (const m of results) {
|
||||||
|
globalSeen.add(m.id);
|
||||||
|
merged.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,22 @@
|
|||||||
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
|
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
|
||||||
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
|
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
|
||||||
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
|
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
|
||||||
UPDATE_CATEGORY_ORDER,
|
UPDATE_CATEGORY_ORDER, UPDATE_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
|
||||||
} from "@api";
|
} from "@api";
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
|
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
|
||||||
import { sortLibrary } from "../lib/librarySort";
|
import { sortLibrary } from "../lib/librarySort";
|
||||||
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
import { startLibraryUpdate } from "../lib/libraryUpdater";
|
||||||
import { createPaginator } from "@core/algorithms/paginate";
|
import { createPaginator } from "@core/algorithms/paginate";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import {
|
import {
|
||||||
store, setCategories, setLibraryUpdates, addToast,
|
store, setCategories, setLibraryUpdates, addToast,
|
||||||
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
|
||||||
} from "../store/libraryState.svelte";
|
} from "../store/libraryState.svelte";
|
||||||
|
import { saveScroll, getScroll } from "@store/state.svelte";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
|
||||||
import type { Manga, Category, Chapter } from "@types";
|
import type { Manga, Category, Chapter } from "@types";
|
||||||
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
|
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
@@ -27,11 +29,12 @@
|
|||||||
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
||||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||||
|
|
||||||
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
|
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
const COMPLETED_NAME = "Completed";
|
const COMPLETED_NAME = "Completed";
|
||||||
|
const CTX_FOLDER_CAP = 4;
|
||||||
|
|
||||||
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
let search: string = $state("");
|
let search: string = $state("");
|
||||||
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
|
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
|
||||||
let scrollEl: HTMLDivElement;
|
let scrollEl: HTMLDivElement;
|
||||||
let tabsEl: HTMLDivElement;
|
let tabsEl = $state<HTMLDivElement>(null!);
|
||||||
let containerWidth: number = $state(800);
|
let containerWidth: number = $state(800);
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
let emptyCtx: { x: number; y: number } | null = $state(null);
|
||||||
@@ -63,11 +66,13 @@
|
|||||||
let refreshDone: boolean = $state(false);
|
let refreshDone: boolean = $state(false);
|
||||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
let refreshingMangaId: number | null = $state(null);
|
||||||
|
let refreshingCatId: number | null = $state(null);
|
||||||
|
|
||||||
let activeDragKind: "tab" | null = $state(null);
|
let activeDragKind: "tab" | null = $state(null);
|
||||||
let dragInsertIdx: number = $state(-1);
|
let dragInsertIdx: number = $state(-1);
|
||||||
let dragTabId: number | null = $state(null);
|
let dragTabId: string | null = $state(null);
|
||||||
let dragOverTabId: number | null = $state(null);
|
let dragOverTabId: string | null = $state(null);
|
||||||
|
|
||||||
|
|
||||||
const DT_TAB = "application/x-moku-tab";
|
const DT_TAB = "application/x-moku-tab";
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
@@ -80,14 +85,47 @@
|
|||||||
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||||
|
|
||||||
|
const BUILTIN_TABS = ["library", "downloaded"] as const;
|
||||||
|
|
||||||
|
const completedCatId = $derived(
|
||||||
|
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTabIds = $derived((() => {
|
||||||
|
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||||
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
|
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
|
||||||
|
|
||||||
|
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
|
||||||
|
|
||||||
|
const virtualTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
|
const folderTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
const visibleCategories = $derived((() => {
|
const visibleCategories = $derived((() => {
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||||
return store.categories
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
const cats = store.categories.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)));
|
||||||
.sort((a, b) => {
|
const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
|
||||||
|
return cats.sort((a, b) => {
|
||||||
if (a.id === defaultId) return -1;
|
if (a.id === defaultId) return -1;
|
||||||
if (b.id === defaultId) return 1;
|
if (b.id === defaultId) return 1;
|
||||||
return a.order - b.order;
|
const pd = pinOrder(a.id) - pinOrder(b.id);
|
||||||
|
return pd !== 0 ? pd : a.order - b.order;
|
||||||
});
|
});
|
||||||
})());
|
})());
|
||||||
|
|
||||||
@@ -134,9 +172,9 @@
|
|||||||
|
|
||||||
const f = store.settings.libraryTabFilters?.[tab] ?? {};
|
const f = store.settings.libraryTabFilters?.[tab] ?? {};
|
||||||
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
|
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
|
||||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||||
if (f.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
|
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
||||||
|
|
||||||
const recentlyReadMap = new Map<number, number>();
|
const recentlyReadMap = new Map<number, number>();
|
||||||
if (tabSortMode === "recentlyRead") {
|
if (tabSortMode === "recentlyRead") {
|
||||||
@@ -167,7 +205,18 @@
|
|||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
||||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
||||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
let prevTab = $state(tab);
|
||||||
|
$effect(() => {
|
||||||
|
const nextTab = tab;
|
||||||
|
if (scrollEl && nextTab !== prevTab) {
|
||||||
|
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
|
||||||
|
const saved = getScroll(`library:${nextTab}`);
|
||||||
|
untrack(() => { scrollEl.scrollTo({ top: saved }); });
|
||||||
|
prevTab = nextTab;
|
||||||
|
} else if (scrollEl && nextTab === prevTab) {
|
||||||
|
scrollEl.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const f = tab;
|
const f = tab;
|
||||||
if (f === "library" || f === "downloaded") return;
|
if (f === "library" || f === "downloaded") return;
|
||||||
@@ -175,7 +224,7 @@
|
|||||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
||||||
});
|
});
|
||||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
||||||
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); });
|
$effect(() => { tab; counts; });
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -184,28 +233,34 @@
|
|||||||
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateTabIndicator() {
|
|
||||||
if (!tabsEl) return;
|
|
||||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
|
||||||
if (!active) return;
|
|
||||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
|
||||||
}
|
|
||||||
|
|
||||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
||||||
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
||||||
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
||||||
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
||||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||||
|
|
||||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
let cardLongPressFired = false;
|
||||||
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
|
||||||
if (e.button !== 0) return;
|
function rootLongPressAction(node: HTMLElement) {
|
||||||
longPressTimer = setTimeout(() => { longPressTimer = null; enterSelectMode(m.id); }, 500);
|
return longPress(node, {
|
||||||
|
onLongPress(e) {
|
||||||
|
if ((e.target as HTMLElement).closest("button, .card")) return;
|
||||||
|
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardLongPress(node: HTMLElement, m: Manga) {
|
||||||
|
return longPress(node, {
|
||||||
|
onLongPress(e) {
|
||||||
|
cardLongPressFired = true;
|
||||||
|
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
|
|
||||||
function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
|
|
||||||
|
|
||||||
function onCardClick(e: MouseEvent, m: Manga) {
|
function onCardClick(e: MouseEvent, m: Manga) {
|
||||||
|
if (cardLongPressFired) { cardLongPressFired = false; return; }
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
if (selectMode) { toggleSelect(m.id); return; }
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
|
||||||
store.activeManga = m;
|
store.activeManga = m;
|
||||||
@@ -233,7 +288,7 @@
|
|||||||
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
|
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
|
||||||
reloadCategories(),
|
reloadCategories(),
|
||||||
]);
|
]);
|
||||||
const mapped = nodes.map((m: any) => ({ ...m, chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0 }));
|
const mapped = nodes.map((m: any) => ({ ...m }));
|
||||||
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
||||||
error = null;
|
error = null;
|
||||||
await migrateCategorizedToLibrary();
|
await migrateCategorizedToLibrary();
|
||||||
@@ -271,6 +326,36 @@
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshManga(manga: Manga) {
|
||||||
|
if (refreshingMangaId !== null) return;
|
||||||
|
refreshingMangaId = manga.id;
|
||||||
|
try {
|
||||||
|
await gql(UPDATE_LIBRARY_MANGA, { id: manga.id });
|
||||||
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
|
await loadData();
|
||||||
|
addToast({ kind: "success", title: "Manga refreshed", body: manga.title, duration: 2500 });
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
finally { refreshingMangaId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCategory(catId: number) {
|
||||||
|
if (refreshingCatId !== null || refreshing) return;
|
||||||
|
refreshingCatId = catId;
|
||||||
|
try {
|
||||||
|
await gql(UPDATE_CATEGORY_MANGA, { categoryId: catId });
|
||||||
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
|
await loadData();
|
||||||
|
const cat = store.categories.find(c => c.id === catId);
|
||||||
|
addToast({ kind: "success", title: "Folder refreshed", body: cat?.name ?? "", duration: 2500 });
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
finally { refreshingCatId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpCategoryFrecency(catId: number) {
|
||||||
|
const prev = (store.settings as any).categoryFrecency ?? {};
|
||||||
|
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
||||||
setCategories(store.categories.map(c => {
|
setCategories(store.categories.map(c => {
|
||||||
@@ -278,6 +363,7 @@
|
|||||||
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
|
||||||
return { ...c, mangas: { nodes } };
|
return { ...c, mangas: { nodes } };
|
||||||
}));
|
}));
|
||||||
|
if (!inCat) bumpCategoryFrecency(cat.id);
|
||||||
try {
|
try {
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
|
||||||
if (!inCat && !manga.inLibrary) {
|
if (!inCat && !manga.inLibrary) {
|
||||||
@@ -296,6 +382,7 @@
|
|||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||||
const cat = res.createCategory.category;
|
const cat = res.createCategory.category;
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||||
|
bumpCategoryFrecency(cat.id);
|
||||||
if (!manga.inLibrary) {
|
if (!manga.inLibrary) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||||
@@ -342,24 +429,38 @@
|
|||||||
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_W = 52;
|
||||||
|
const TITLEBAR_H = 36;
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
if (selectMode) { toggleSelect(m.id); return; }
|
if (selectMode) { toggleSelect(m.id); return; }
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
|
const frecency: Record<number, number> = (store.settings as any).categoryFrecency ?? {};
|
||||||
|
const sorted = [...visibleCategories].sort((a, b) => (frecency[b.id] ?? 0) - (frecency[a.id] ?? 0));
|
||||||
|
const pinned = sorted.slice(0, CTX_FOLDER_CAP);
|
||||||
|
const overflow = sorted.slice(CTX_FOLDER_CAP);
|
||||||
|
|
||||||
|
const makeCatEntry = (cat: Category): MenuEntry => {
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||||||
return { label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const pinnedEntries = pinned.map(makeCatEntry);
|
||||||
|
const overflowChildren = overflow.map(makeCatEntry);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||||
|
{ label: refreshingMangaId === m.id ? "Refreshing…" : "Refresh manga", icon: ArrowsClockwise, disabled: refreshingMangaId !== null, onClick: () => refreshManga(m) },
|
||||||
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||||||
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
|
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
|
||||||
|
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||||
];
|
];
|
||||||
@@ -387,18 +488,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelLibraryRefresh() {
|
||||||
|
if (!refreshing) return;
|
||||||
|
try { await gql(UPDATE_STOP); } catch (e) { console.error(e); }
|
||||||
|
cancelUpdate?.();
|
||||||
|
cancelUpdate = null;
|
||||||
|
refreshing = false;
|
||||||
|
refreshProgress = { finished: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
async function startLibraryRefresh() {
|
async function startLibraryRefresh() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
refreshProgress = { finished: 0, total: 0 };
|
refreshProgress = { finished: 0, total: 0 };
|
||||||
|
|
||||||
cancelUpdate = startLibraryUpdate({
|
cancelUpdate = startLibraryUpdate({
|
||||||
onProgress(p) {
|
onProgress(p) { refreshProgress = p; },
|
||||||
refreshProgress = p;
|
|
||||||
},
|
|
||||||
async onDone({ entries, totalUpdated, newChapters }) {
|
async onDone({ entries, totalUpdated, newChapters }) {
|
||||||
refreshing = false;
|
refreshing = false; cancelUpdate = null;
|
||||||
cancelUpdate = null;
|
|
||||||
setLibraryUpdates(entries);
|
setLibraryUpdates(entries);
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -407,49 +514,59 @@
|
|||||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||||||
showToast(newChapters, totalUpdated);
|
showToast(newChapters, totalUpdated);
|
||||||
},
|
},
|
||||||
onError() {
|
onError() { refreshing = false; cancelUpdate = null; },
|
||||||
refreshing = false;
|
|
||||||
cancelUpdate = null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragStart(e: DragEvent, cat: Category) {
|
function onTabDragStart(e: DragEvent, id: string) {
|
||||||
activeDragKind = "tab"; dragTabId = cat.id;
|
activeDragKind = "tab"; dragTabId = id;
|
||||||
e.dataTransfer!.effectAllowed = "move";
|
e.dataTransfer!.effectAllowed = "move";
|
||||||
e.dataTransfer!.setData(DT_TAB, String(cat.id));
|
e.dataTransfer!.setData(DT_TAB, id);
|
||||||
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
|
e.dataTransfer!.setData("text/plain", `tab:${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
|
||||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
||||||
dragOverTabId = cat.id;
|
dragOverTabId = id;
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragLeave() { dragOverTabId = null; }
|
function onTabDragLeave() { dragOverTabId = null; }
|
||||||
|
|
||||||
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
async function onTabDrop(e: DragEvent, dropId: string) {
|
||||||
e.preventDefault(); dragOverTabId = null;
|
e.preventDefault(); dragOverTabId = null;
|
||||||
const insertAt = dragInsertIdx;
|
const insertAt = dragInsertIdx;
|
||||||
dragInsertIdx = -1;
|
dragInsertIdx = -1;
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
|
||||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||||
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
|
||||||
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
const tabs = [...allTabIds];
|
||||||
if (fromIdx < 0) return;
|
const fromIdx = tabs.indexOf(dragStrId);
|
||||||
const reordered = [...sorted];
|
const dropIdx = tabs.indexOf(dropId);
|
||||||
const [moved] = reordered.splice(fromIdx, 1);
|
if (fromIdx < 0 || dropIdx < 0) return;
|
||||||
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, reordered.length));
|
|
||||||
reordered.splice(dest, 0, moved);
|
const visibleDrop = visibleTabIds[insertAt] ?? null;
|
||||||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
|
||||||
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
|
||||||
|
tabs.splice(fromIdx, 1);
|
||||||
|
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 dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
|
||||||
|
if (!dragIsBuiltin) {
|
||||||
|
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||||
try {
|
try {
|
||||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: dest + 1 });
|
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
|
||||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
||||||
|
|
||||||
@@ -475,7 +592,6 @@
|
|||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
document.addEventListener("mousedown", onDocMouseDown, true);
|
||||||
requestAnimationFrame(updateTabIndicator);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect(); unsub();
|
ro.disconnect(); unsub();
|
||||||
@@ -490,10 +606,11 @@
|
|||||||
class="root"
|
class="root"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
bind:this={scrollEl}
|
bind:this={scrollEl}
|
||||||
|
use:rootLongPressAction
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emptyCtx = { x: e.clientX, y: e.clientY };
|
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if store.settings.libraryBranches ?? true}
|
{#if store.settings.libraryBranches ?? true}
|
||||||
@@ -533,13 +650,17 @@
|
|||||||
{tabFilters}
|
{tabFilters}
|
||||||
{hasActiveFilters}
|
{hasActiveFilters}
|
||||||
{anims}
|
{anims}
|
||||||
{tabIndicator}
|
|
||||||
{visibleCategories}
|
{visibleCategories}
|
||||||
|
{visibleTabIds}
|
||||||
|
{virtualTabIds}
|
||||||
|
{folderTabIds}
|
||||||
|
{completedCatId}
|
||||||
{counts}
|
{counts}
|
||||||
{search}
|
{search}
|
||||||
{refreshing}
|
{refreshing}
|
||||||
{refreshProgress}
|
{refreshProgress}
|
||||||
{refreshDone}
|
{refreshDone}
|
||||||
|
{refreshingCatId}
|
||||||
{activeDragKind}
|
{activeDragKind}
|
||||||
{dragInsertIdx}
|
{dragInsertIdx}
|
||||||
{dragTabId}
|
{dragTabId}
|
||||||
@@ -557,6 +678,8 @@
|
|||||||
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
|
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
|
||||||
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
|
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
|
||||||
onRefresh={startLibraryRefresh}
|
onRefresh={startLibraryRefresh}
|
||||||
|
onCancelRefresh={cancelLibraryRefresh}
|
||||||
|
onRefreshCategory={refreshCategory}
|
||||||
onOpenDownloadsFolder={openDownloadsFolder}
|
onOpenDownloadsFolder={openDownloadsFolder}
|
||||||
onTabDragStart={onTabDragStart}
|
onTabDragStart={onTabDragStart}
|
||||||
onTabDragOver={onTabDragOver}
|
onTabDragOver={onTabDragOver}
|
||||||
@@ -588,9 +711,7 @@
|
|||||||
libraryFilter={tab}
|
libraryFilter={tab}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
onCardContextMenu={openCtx}
|
onCardContextMenu={openCtx}
|
||||||
onCardPointerDown={onCardPointerDown}
|
onCardLongPress={cardLongPress}
|
||||||
onCardPointerUp={onCardPointerUp}
|
|
||||||
onCardPointerLeave={onCardPointerLeave}
|
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
onRetry={() => retryCount++}
|
onRetry={() => retryCount++}
|
||||||
onExitSelectMode={exitSelectMode}
|
onExitSelectMode={exitSelectMode}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { Manga, Category } from "@types";
|
import type { Manga, Category } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,9 +23,7 @@
|
|||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||||
onCardPointerDown: (e: PointerEvent, m: Manga) => void;
|
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
|
||||||
onCardPointerUp: () => void;
|
|
||||||
onCardPointerLeave: () => void;
|
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onExitSelectMode: () => void;
|
onExitSelectMode: () => void;
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||||
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||||
bulkWorking, visibleCategories,
|
bulkWorking, visibleCategories,
|
||||||
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
onCardClick, onCardContextMenu, onCardLongPress,
|
||||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
<div class="content" role="presentation" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each Array(12) as _}
|
{#each Array(12) as _}
|
||||||
@@ -115,20 +115,18 @@
|
|||||||
<div class="grid" style="--cols:{cols}">
|
<div class="grid" style="--cols:{cols}">
|
||||||
{#each visibleManga as m (m.id)}
|
{#each visibleManga as m (m.id)}
|
||||||
{@const isSelected = selectedIds.has(m.id)}
|
{@const isSelected = selectedIds.has(m.id)}
|
||||||
{@const isCompleted = !m.unreadCount && (m.chapterCount ?? 0) > 0}
|
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
|
||||||
<button
|
<button
|
||||||
class="card"
|
class="card"
|
||||||
class:card-selected={isSelected}
|
class:card-selected={isSelected}
|
||||||
class:select-mode={selectMode}
|
class:select-mode={selectMode}
|
||||||
class:anims={anims}
|
class:anims={anims}
|
||||||
|
use:onCardLongPress={m}
|
||||||
onclick={(e) => onCardClick(e, m)}
|
onclick={(e) => onCardClick(e, m)}
|
||||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||||
onpointerdown={(e) => onCardPointerDown(e, m)}
|
|
||||||
onpointerup={onCardPointerUp}
|
|
||||||
onpointerleave={onCardPointerLeave}
|
|
||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
<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="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||||
<div class="overlay-badges">
|
<div class="overlay-badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
@@ -186,7 +184,6 @@
|
|||||||
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
.grid { position: relative; z-index: 1; isolation: isolate; 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 { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.card.anims:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
.card.anims:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||||
.card.anims:not(.select-mode):hover .cover { filter: brightness(1.1); }
|
|
||||||
.card:not(.select-mode):hover .title { color: var(--text-primary); }
|
.card:not(.select-mode):hover .title { color: var(--text-primary); }
|
||||||
.card.select-mode { cursor: default; }
|
.card.select-mode { cursor: default; }
|
||||||
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
|
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
|
||||||
@@ -194,8 +191,7 @@
|
|||||||
.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; }
|
.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); }
|
.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); }
|
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||||
.card.anims .cover { transition: filter var(--t-base); }
|
.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 { position: absolute; bottom: -4px; left: 0; right: 0; 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.anim { transition: opacity 0.18s ease; }
|
||||||
.card-info-overlay.instant { transition: none; }
|
.card-info-overlay.instant { transition: none; }
|
||||||
.card-info-overlay.always { opacity: 1; }
|
.card-info-overlay.always { opacity: 1; }
|
||||||
@@ -205,7 +201,7 @@
|
|||||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
.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-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; }
|
.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; }
|
||||||
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
.select-overlay { position: absolute; inset: 0; z-index: 3; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
||||||
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
||||||
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
||||||
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
|
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star,
|
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
@@ -15,17 +15,17 @@
|
|||||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||||
hasActiveFilters: boolean;
|
hasActiveFilters: boolean;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
tabIndicator: { left: number; width: number };
|
|
||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
|
visibleTabIds: string[];
|
||||||
|
virtualTabIds: string[];
|
||||||
|
folderTabIds: string[];
|
||||||
|
completedCatId: number | null;
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
search: string;
|
search: string;
|
||||||
refreshing: boolean;
|
|
||||||
refreshProgress: { finished: number; total: number };
|
|
||||||
refreshDone: boolean;
|
|
||||||
activeDragKind: "tab" | null;
|
activeDragKind: "tab" | null;
|
||||||
dragInsertIdx: number;
|
dragInsertIdx: number;
|
||||||
dragTabId: number | null;
|
dragTabId: string | null;
|
||||||
dragOverTabId: number | null;
|
dragOverTabId: string | null;
|
||||||
sortPanelOpen: boolean;
|
sortPanelOpen: boolean;
|
||||||
filterPanelOpen: boolean;
|
filterPanelOpen: boolean;
|
||||||
tabsEl: HTMLDivElement;
|
tabsEl: HTMLDivElement;
|
||||||
@@ -38,20 +38,19 @@
|
|||||||
onFiltersClear: () => void;
|
onFiltersClear: () => void;
|
||||||
onSortPanelToggle: () => void;
|
onSortPanelToggle: () => void;
|
||||||
onFilterPanelToggle: () => void;
|
onFilterPanelToggle: () => void;
|
||||||
onRefresh: () => void;
|
|
||||||
onOpenDownloadsFolder: () => void;
|
onOpenDownloadsFolder: () => void;
|
||||||
onTabDragStart: (e: DragEvent, cat: Category) => void;
|
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||||
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
|
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||||
onTabDragLeave: () => void;
|
onTabDragLeave: () => void;
|
||||||
onTabDrop: (e: DragEvent, cat: Category) => void;
|
onTabDrop: (e: DragEvent, id: string) => void;
|
||||||
onTabDragEnd: () => void;
|
onTabDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||||
anims, tabIndicator, visibleCategories, counts, search, refreshing,
|
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||||
refreshProgress, refreshDone, activeDragKind, dragInsertIdx, dragTabId,
|
counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
|
||||||
dragOverTabId, sortPanelOpen, filterPanelOpen,
|
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||||
tabsEl = $bindable(),
|
tabsEl = $bindable(),
|
||||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||||
@@ -59,6 +58,26 @@
|
|||||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function onTabsWheel(e: WheelEvent) {
|
||||||
|
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
||||||
|
const idx = ids.indexOf(tab);
|
||||||
|
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
||||||
|
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tab;
|
||||||
|
if (!tabsEl) return;
|
||||||
|
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||||
|
if (!active) return;
|
||||||
|
const pl = tabsEl.scrollLeft;
|
||||||
|
const cw = tabsEl.clientWidth;
|
||||||
|
const ol = active.offsetLeft;
|
||||||
|
const ow = active.offsetWidth;
|
||||||
|
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
|
||||||
|
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||||||
az: "A–Z",
|
az: "A–Z",
|
||||||
unreadCount: "Unread chapters",
|
unreadCount: "Unread chapters",
|
||||||
@@ -72,54 +91,46 @@
|
|||||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Library</span>
|
<span class="heading">Library</span>
|
||||||
|
|
||||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
|
||||||
{#if anims && tabIndicator.width > 0}
|
{#each visibleTabIds as id, idx}
|
||||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||||
{/if}
|
{#if id === "library" || id === "downloaded" || cat}
|
||||||
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
|
{@const isBuiltin = id === "library" || id === "downloaded"}
|
||||||
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
|
{@const isCompleted = cat && id === String(completedCatId)}
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
{@const isDraggable = true}
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
{#if activeDragKind === "tab" && dragInsertIdx === idx}
|
||||||
{label}
|
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if visibleCategories.length > 0}
|
|
||||||
<div class="tab-separator" aria-hidden="true"></div>
|
|
||||||
<div class="tabs-scroll">
|
|
||||||
{#each visibleCategories as cat, idx}
|
|
||||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
class:active={tab === String(cat.id)}
|
class:active={tab === id}
|
||||||
class:tab-dragging={dragTabId === cat.id}
|
class:tab-dragging={isDraggable && dragTabId === id}
|
||||||
draggable="true"
|
draggable={isDraggable}
|
||||||
onclick={() => onTabChange(String(cat.id))}
|
onclick={() => onTabChange(id)}
|
||||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
|
||||||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
|
||||||
ondragleave={onTabDragLeave}
|
ondragleave={isDraggable ? onTabDragLeave : undefined}
|
||||||
ondrop={(e) => onTabDrop(e, cat)}
|
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
|
||||||
ondragend={onTabDragEnd}
|
ondragend={isDraggable ? onTabDragEnd : undefined}
|
||||||
>
|
>
|
||||||
<Folder size={11} weight="bold" />
|
{#if id === "library"}<Books size={11} weight="bold" />
|
||||||
{cat.name}
|
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
|
||||||
|
{:else if cat}<Folder size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
|
||||||
|
<span class="tab-count">{counts[id] ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -128,19 +139,27 @@
|
|||||||
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
|
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if refreshing}
|
||||||
<button
|
<button
|
||||||
class="icon-btn refresh-btn"
|
class="icon-btn refresh-btn icon-btn-active"
|
||||||
class:icon-btn-active={refreshing}
|
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
|
||||||
class:refresh-btn-done={refreshDone}
|
|
||||||
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"}
|
|
||||||
disabled={refreshing}
|
|
||||||
onclick={onRefresh}
|
onclick={onRefresh}
|
||||||
>
|
>
|
||||||
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
|
||||||
{#if refreshing && refreshProgress.total > 0}
|
{#if refreshProgress.total > 0}
|
||||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="icon-btn refresh-btn"
|
||||||
|
class:refresh-btn-done={refreshDone}
|
||||||
|
title={refreshDone ? "Library updated" : "Check for updates"}
|
||||||
|
onclick={onRefresh}
|
||||||
|
>
|
||||||
|
<ArrowsClockwise size={15} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
|
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
|
||||||
<FolderSimple size={15} weight="bold" />
|
<FolderSimple size={15} weight="bold" />
|
||||||
@@ -202,15 +221,11 @@
|
|||||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; }
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
|
||||||
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
.tabs-scroll::-webkit-scrollbar { display: none; }
|
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||||
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
|
|
||||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
|
||||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; flex-shrink: 0; }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
.tabs-anims .tab.active { background: transparent; }
|
|
||||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
@@ -222,6 +237,7 @@
|
|||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||||
.refresh-btn:disabled { cursor: default; }
|
.refresh-btn:disabled { cursor: default; }
|
||||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||||
@@ -236,11 +252,6 @@
|
|||||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
.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-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-item-check { justify-content: flex-start; gap: var(--sp-2); }
|
|
||||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
|
|
||||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
|
||||||
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
||||||
:global(.sort-caret) { flex-shrink: 0; }
|
:global(.sort-caret) { flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
@@ -16,11 +16,11 @@ export const librarySorter = createSorter<Manga>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "totalChapters",
|
key: "totalChapters",
|
||||||
comparator: (a, b) => (a.chapterCount ?? 0) - (b.chapterCount ?? 0),
|
comparator: (a, b) => (a.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recentlyAdded",
|
key: "recentlyAdded",
|
||||||
comparator: (a, b) => a.id - b.id,
|
comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recentlyRead",
|
key: "recentlyRead",
|
||||||
@@ -33,11 +33,11 @@ export const librarySorter = createSorter<Manga>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "latestFetched",
|
key: "latestFetched",
|
||||||
comparator: (a, b) => a.id - b.id,
|
comparator: (a, b) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "latestUploaded",
|
key: "latestUploaded",
|
||||||
comparator: (a, b) => a.id - b.id,
|
comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
|
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
|
||||||
import { UPDATE_LIBRARY } from "@api/mutations/manga";
|
import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga";
|
||||||
import { GET_RECENTLY_UPDATED } from "@api/queries/chapters";
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
import type { LibraryUpdateEntry } from "@store/state.svelte";
|
import type { LibraryUpdateEntry } from "@store/state.svelte";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 2000;
|
||||||
const POLL_INITIAL_MS = 2000;
|
const POLL_INITIAL_MS = 500;
|
||||||
|
|
||||||
export interface UpdateProgress {
|
export interface UpdateProgress {
|
||||||
finished: number;
|
finished: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
skippedManga: number;
|
||||||
|
skippedCategories: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateResult {
|
export interface UpdateResult {
|
||||||
@@ -21,89 +23,138 @@ export interface UpdateResult {
|
|||||||
export interface LibraryUpdaterCallbacks {
|
export interface LibraryUpdaterCallbacks {
|
||||||
onProgress: (p: UpdateProgress) => void;
|
onProgress: (p: UpdateProgress) => void;
|
||||||
onDone: (r: UpdateResult) => void;
|
onDone: (r: UpdateResult) => void;
|
||||||
onError: () => void;
|
onError: (e?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshLibraryMetadata(
|
||||||
|
onProgress?: (done: number, total: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LIBRARY, {});
|
||||||
|
const ids = data.mangas.nodes.map(m => m.id);
|
||||||
|
let done = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await gql(FETCH_MANGA, { id });
|
||||||
|
} catch {}
|
||||||
|
onProgress?.(++done, ids.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
|
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const startedAt = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer) { clearTimeout(timer); timer = null; }
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEntries(
|
||||||
|
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
|
||||||
|
): LibraryUpdateEntry[] {
|
||||||
|
const byManga = new Map<number, LibraryUpdateEntry>();
|
||||||
|
for (const u of mangaUpdates) {
|
||||||
|
if (u.status !== "UPDATED") continue;
|
||||||
|
const existing = byManga.get(u.manga.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.newChapters++;
|
||||||
|
} else {
|
||||||
|
byManga.set(u.manga.id, {
|
||||||
|
mangaId: u.manga.id,
|
||||||
|
mangaTitle: u.manga.title,
|
||||||
|
thumbnailUrl: u.manga.thumbnailUrl,
|
||||||
|
newChapters: 1,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byManga.values()];
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
let seenWork = false;
|
let jobsStarted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await gql<{
|
const res = await gql<{
|
||||||
updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } }
|
updateLibrary: {
|
||||||
|
updateStatus: {
|
||||||
|
jobsInfo: {
|
||||||
|
isRunning: boolean;
|
||||||
|
totalJobs: number;
|
||||||
|
finishedJobs: number;
|
||||||
|
skippedMangasCount: number;
|
||||||
|
skippedCategoriesCount: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}>(UPDATE_LIBRARY, {});
|
}>(UPDATE_LIBRARY, {});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
seenWork = res.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
|
|
||||||
} catch {
|
const { jobsInfo } = res.updateLibrary.updateStatus;
|
||||||
if (!cancelled) callbacks.onError();
|
jobsStarted = jobsInfo.totalJobs > 0;
|
||||||
|
|
||||||
|
callbacks.onProgress({
|
||||||
|
finished: jobsInfo.finishedJobs,
|
||||||
|
total: jobsInfo.totalJobs,
|
||||||
|
skippedManga: jobsInfo.skippedMangasCount,
|
||||||
|
skippedCategories: jobsInfo.skippedCategoriesCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!jobsStarted) {
|
||||||
|
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobsStarted && !jobsInfo.isRunning) {
|
||||||
|
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[libraryUpdater] failed to start update", e);
|
||||||
|
if (!cancelled) callbacks.onError(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function poll() {
|
function poll() {
|
||||||
gql<{
|
gql<{
|
||||||
libraryUpdateStatus: {
|
libraryUpdateStatus: {
|
||||||
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
|
jobsInfo: {
|
||||||
mangaUpdates: { status: string; manga: { id: number } }[];
|
isRunning: boolean;
|
||||||
|
finishedJobs: number;
|
||||||
|
totalJobs: number;
|
||||||
|
skippedMangasCount: number;
|
||||||
|
skippedCategoriesCount: number;
|
||||||
|
};
|
||||||
|
mangaUpdates: {
|
||||||
|
status: string;
|
||||||
|
manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number };
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
}>(LIBRARY_UPDATE_STATUS, {})
|
}>(LIBRARY_UPDATE_STATUS, {})
|
||||||
.then(async d => {
|
.then(async d => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const { jobsInfo } = d.libraryUpdateStatus;
|
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
|
||||||
|
|
||||||
if (jobsInfo.totalJobs > 0) seenWork = true;
|
if (jobsInfo.totalJobs > 0) jobsStarted = true;
|
||||||
callbacks.onProgress({ finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs });
|
callbacks.onProgress({
|
||||||
|
finished: jobsInfo.finishedJobs,
|
||||||
if (!jobsInfo.isRunning && seenWork) {
|
total: jobsInfo.totalJobs,
|
||||||
const recent = await gql<{
|
skippedManga: jobsInfo.skippedMangasCount,
|
||||||
chapters: {
|
skippedCategories: jobsInfo.skippedCategoriesCount,
|
||||||
nodes: {
|
|
||||||
mangaId: number;
|
|
||||||
fetchedAt: string;
|
|
||||||
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const byManga = new Map<number, LibraryUpdateEntry>();
|
|
||||||
for (const ch of recent.chapters.nodes) {
|
|
||||||
if (!ch.manga.inLibrary) continue;
|
|
||||||
if (Number(ch.fetchedAt) < startedAt) continue;
|
|
||||||
const existing = byManga.get(ch.mangaId);
|
|
||||||
if (existing) {
|
|
||||||
existing.newChapters++;
|
|
||||||
} else {
|
|
||||||
byManga.set(ch.mangaId, {
|
|
||||||
mangaId: ch.mangaId,
|
|
||||||
mangaTitle: ch.manga.title,
|
|
||||||
thumbnailUrl: ch.manga.thumbnailUrl,
|
|
||||||
newChapters: 1,
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = [...byManga.values()];
|
if (!jobsInfo.isRunning && jobsStarted) {
|
||||||
|
const entries = buildEntries(mangaUpdates);
|
||||||
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
|
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
|
||||||
|
|
||||||
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
|
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
if (!cancelled) callbacks.onError();
|
console.error("[libraryUpdater] poll error", e);
|
||||||
|
if (!cancelled) callbacks.onError(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from "phosphor-svelte";
|
import { X } from "phosphor-svelte";
|
||||||
import { setPref } from "@features/series/lib/mangaPrefs";
|
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||||
import { store } from "@store/state.svelte";
|
import { store, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
import { resolvedCover } from "@core/cover/coverResolver";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
|
|
||||||
let { ids, onClose }: {
|
let { ids, onClose }: {
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
||||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
||||||
|
|
||||||
|
const mosaicCovers = $derived.by(() => {
|
||||||
|
const idArr = [...ids].slice(0, 9);
|
||||||
|
return idArr
|
||||||
|
.map(id => store.library?.find(m => m.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(m => resolvedCover(m!.id, m!.thumbnailUrl));
|
||||||
|
});
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
||||||
@@ -60,12 +68,26 @@
|
|||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
<div class="header-inner">
|
||||||
<div class="header-left">
|
<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-title">Automation</span>
|
||||||
<span class="modal-subtitle">{ids.size} series selected</span>
|
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
@@ -214,13 +236,37 @@
|
|||||||
animation: scaleIn 0.15s ease both;
|
animation: scaleIn 0.15s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header { border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
padding: var(--sp-4) var(--sp-5); gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
.header-left { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||||
|
|
||||||
|
.mosaic {
|
||||||
|
display: flex; align-items: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mosaic-tile {
|
||||||
|
width: 28px; height: 38px;
|
||||||
|
object-fit: cover; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
margin-left: -6px; box-shadow: -1px 0 0 var(--bg-surface);
|
||||||
|
}
|
||||||
|
.mosaic-tile:first-child { margin-left: 0; }
|
||||||
|
|
||||||
|
.mosaic-overflow {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
margin-left: var(--sp-1); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch } from "phosphor-svelte";
|
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import type { StripChapter } from "../lib/scrollHandler";
|
import type { StripChapter } from "../lib/scrollHandler";
|
||||||
@@ -19,6 +18,8 @@
|
|||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
chapterEpoch: number;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
@@ -31,10 +32,152 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
tapToToggleBar, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
|
||||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
||||||
|
|
||||||
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
|
const out: FlatPage[] = [];
|
||||||
|
for (const chunk of stripToRender) {
|
||||||
|
for (let i = 0; i < chunk.urls.length; i++) {
|
||||||
|
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let loadedSet = $state(new Set<number>());
|
||||||
|
let resolvedSrc = $state<Record<number, string>>({});
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
const elementIndex = new Map<Element, number>();
|
||||||
|
|
||||||
|
let viewportCenter = $state(0);
|
||||||
|
|
||||||
|
function scheduleRevoke(src: string) {
|
||||||
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
|
revokeQueue.push(src);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const url = revokeQueue.shift();
|
||||||
|
if (url) {
|
||||||
|
try { URL.revokeObjectURL(url); } catch { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(idx: number) {
|
||||||
|
if (loadedSet.has(idx)) return;
|
||||||
|
const page = flatPages[idx];
|
||||||
|
if (!page) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.add(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
||||||
|
resolveUrl(page.url, priority).then(src => {
|
||||||
|
if (loadedSet.has(idx)) {
|
||||||
|
resolvedSrc = { ...resolvedSrc, [idx]: src };
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!loadedSet.has(idx)) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.delete(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const oldSrc = resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
const next = { ...resolvedSrc };
|
||||||
|
delete next[idx];
|
||||||
|
resolvedSrc = next;
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcWindow() {
|
||||||
|
const center = viewportCenter;
|
||||||
|
const lo = center - LOAD_RADIUS;
|
||||||
|
const hi = center + LOAD_RADIUS;
|
||||||
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
|
const evictHi = center + UNLOAD_RADIUS;
|
||||||
|
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (i >= lo && i <= hi) {
|
||||||
|
loadPage(i);
|
||||||
|
} else if (i < evictLo || i > evictHi) {
|
||||||
|
unloadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void viewportCenter;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void flatPages.length;
|
||||||
|
recalcWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupObserver(containerEl: HTMLElement) {
|
||||||
|
observer?.disconnect();
|
||||||
|
elementIndex.clear();
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let best = -1;
|
||||||
|
let bestRatio = -1;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const idx = elementIndex.get(entry.target);
|
||||||
|
if (idx === undefined) continue;
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
||||||
|
bestRatio = entry.intersectionRatio;
|
||||||
|
best = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0 && best !== viewportCenter) {
|
||||||
|
viewportCenter = best;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: containerEl,
|
||||||
|
rootMargin: "0px",
|
||||||
|
threshold: [0, 0.1, 0.5, 1.0],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observePage(el: HTMLDivElement, idx: number) {
|
||||||
|
elementIndex.set(el, idx);
|
||||||
|
observer?.observe(el);
|
||||||
|
return {
|
||||||
|
update(newIdx: number) {
|
||||||
|
elementIndex.set(el, newIdx);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
observer?.unobserve(el);
|
||||||
|
elementIndex.delete(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void chapterEpoch;
|
||||||
|
loadedSet = new Set<number>();
|
||||||
|
resolvedSrc = {};
|
||||||
|
const resume = readerState.resumePage;
|
||||||
|
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
@@ -63,11 +206,70 @@
|
|||||||
let inspectPanStartX = 0;
|
let inspectPanStartX = 0;
|
||||||
let inspectPanStartY = 0;
|
let inspectPanStartY = 0;
|
||||||
|
|
||||||
let stripDragging = false;
|
let stripDragging = $state(false);
|
||||||
let stripDragMoved = false;
|
let stripDragMoved = false;
|
||||||
let stripDragStartY = 0;
|
let stripDragStartY = 0;
|
||||||
let stripScrollStart = 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;
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -86,11 +288,22 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function onInspectMouseDown(e: MouseEvent) {
|
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") {
|
if (style === "longstrip") {
|
||||||
stripDragging = true;
|
stripDragging = true;
|
||||||
stripDragMoved = false;
|
stripDragMoved = false;
|
||||||
stripDragStartY = e.clientY;
|
stripDragStartY = e.clientY;
|
||||||
stripScrollStart = containerEl?.scrollTop ?? 0;
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
pauseAutoScroll();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,6 +318,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseMove(e: MouseEvent) {
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
|
midScrollCurrentY = e.clientY;
|
||||||
if (stripDragging) {
|
if (stripDragging) {
|
||||||
const dy = e.clientY - stripDragStartY;
|
const dy = e.clientY - stripDragStartY;
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
@@ -126,6 +340,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerDown(e: PointerEvent) {
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
pinch?.onPointerDown(e);
|
pinch?.onPointerDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +375,7 @@
|
|||||||
export function handleWheel(e: WheelEvent) {
|
export function handleWheel(e: WheelEvent) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (e.ctrlKey) { onWheel(e); }
|
if (e.ctrlKey) { onWheel(e); }
|
||||||
|
else pauseAutoScroll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.ctrlKey) { onWheel(e); return; }
|
if (!e.ctrlKey) { onWheel(e); return; }
|
||||||
@@ -183,7 +399,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
if (style === "longstrip") return;
|
if (style === "longstrip") {
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||||
onTap(e);
|
onTap(e);
|
||||||
@@ -192,7 +411,18 @@
|
|||||||
function setContainer(el: HTMLDivElement) {
|
function setContainer(el: HTMLDivElement) {
|
||||||
containerEl = el;
|
containerEl = el;
|
||||||
bindContainer(el);
|
bindContainer(el);
|
||||||
|
if (style === "longstrip") setupObserver(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" && containerEl) {
|
||||||
|
setupObserver(containerEl);
|
||||||
|
} else if (style !== "longstrip") {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
stopMidScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -200,43 +430,94 @@
|
|||||||
class="viewer"
|
class="viewer"
|
||||||
class:strip={style === "longstrip"}
|
class:strip={style === "longstrip"}
|
||||||
class:inspect-active={readerState.inspectScale > 1}
|
class:inspect-active={readerState.inspectScale > 1}
|
||||||
|
class:midscroll-active={midScrollActive}
|
||||||
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={handleTap}
|
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}
|
onmousedown={onInspectMouseDown}
|
||||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||||
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
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}
|
{#if loading}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true"><svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg></div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#key chapterEpoch}
|
||||||
{#if style === "longstrip"}
|
{#if style === "longstrip"}
|
||||||
{#each stripToRender as chunk}
|
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||||
{#each chunk.urls as url, i}
|
{@const src = resolvedSrc[gi]}
|
||||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
<div
|
||||||
{:then src}
|
class="strip-slot"
|
||||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
use:observePage={gi}
|
||||||
{/await}
|
data-gi={gi}
|
||||||
{/each}
|
>
|
||||||
|
{#if isLoaded && src}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
|
data-local-page={page.localIndex + 1}
|
||||||
|
data-chapter={page.chapterId}
|
||||||
|
data-total={page.total}
|
||||||
|
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
onload={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
|
if (slot && img.naturalWidth > 0) {
|
||||||
|
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<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}
|
{/each}
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
|
|
||||||
{:else if style === "fade" && pageReady}
|
{:else if style === "fade" && pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
<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}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -244,33 +525,42 @@
|
|||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#if pageGroups.length}
|
{#if pageGroups.length}
|
||||||
<div class="double-wrap">
|
<div class="double-wrap">
|
||||||
{#each currentGroup as pg, i}
|
{#each currentGroup as pg, i (pg)}
|
||||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
<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}
|
{:then src}
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||||
{/await}
|
{/await}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if pageReady}
|
{:else if pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<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}
|
{:then src}
|
||||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
.viewer:focus { outline: none; }
|
.viewer:focus { outline: none; }
|
||||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||||
@@ -280,11 +570,63 @@
|
|||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.strip-slot {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-placeholder {
|
||||||
|
width: var(--effective-width, 100%);
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
|
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 { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||||
:global(.strip-gap) { margin-bottom: 8px; }
|
:global(.strip-gap) { margin-bottom: 8px; }
|
||||||
|
|
||||||
@@ -295,4 +637,74 @@
|
|||||||
|
|
||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
|
||||||
|
.midscroll-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>
|
</style>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
|
|
||||||
const effectiveReaderSettings = $derived.by(() => {
|
const effectiveReaderSettings = $derived.by(() => {
|
||||||
const mangaId = store.activeManga?.id;
|
const mangaId = store.activeManga?.id;
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
let containerEl: HTMLDivElement | null = null;
|
let containerEl: HTMLDivElement | null = null;
|
||||||
let pageViewRef: PageView;
|
let pageViewRef: PageView;
|
||||||
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
let hideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
let markedRead = new Set<number>();
|
let markedRead = new Set<number>();
|
||||||
let appending = false;
|
let appending = false;
|
||||||
let abortCtrl = { current: null as AbortController | null };
|
let abortCtrl = { current: null as AbortController | null };
|
||||||
@@ -225,6 +225,7 @@
|
|||||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||||
openSettings: () => setSettingsOpen(true),
|
openSettings: () => setSettingsOpen(true),
|
||||||
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
||||||
|
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) }); },
|
||||||
toggleMarker: () => {
|
toggleMarker: () => {
|
||||||
if (currentPageMarkers.length > 0) {
|
if (currentPageMarkers.length > 0) {
|
||||||
const first = currentPageMarkers[0];
|
const first = currentPageMarkers[0];
|
||||||
@@ -420,23 +421,28 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
const current = store.pageUrls[store.pageNumber - 1];
|
const current = store.pageUrls[store.pageNumber - 1];
|
||||||
|
const pageNum = store.pageNumber;
|
||||||
|
const urls = store.pageUrls;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
if (useBlob) {
|
if (useBlob) {
|
||||||
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||||
getBlobUrl(current, 999);
|
getBlobUrl(current, 999);
|
||||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
const behind = urls[pageNum - 2];
|
||||||
preloadBlobUrls(upcoming, ahead);
|
preloadBlobUrls(upcoming, ahead);
|
||||||
if (behind) preloadBlobUrls([behind], 0);
|
if (behind) preloadBlobUrls([behind], 0);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
for (let i = 1; i <= ahead; i++) {
|
for (let i = 1; i <= ahead; i++) {
|
||||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
const url = urls[pageNum - 1 + i];
|
||||||
if (url) preloadImage(url, useBlob);
|
if (url) preloadImage(url, useBlob);
|
||||||
}
|
}
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
const behind = urls[pageNum - 2];
|
||||||
if (behind) preloadImage(behind, useBlob);
|
if (behind) preloadImage(behind, useBlob);
|
||||||
}
|
}
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -587,6 +593,7 @@
|
|||||||
fadingOut={readerState.fadingOut}
|
fadingOut={readerState.fadingOut}
|
||||||
{tapToToggleBar}
|
{tapToToggleBar}
|
||||||
{pinchZoomEnabled}
|
{pinchZoomEnabled}
|
||||||
|
{barPosition}
|
||||||
onGetZoom={() => zoom}
|
onGetZoom={() => zoom}
|
||||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||||
|
|||||||
@@ -142,12 +142,18 @@
|
|||||||
{#if isVertical}
|
{#if isVertical}
|
||||||
<span class="ch-info"></span>
|
<span class="ch-info"></span>
|
||||||
{:else}
|
{:else}
|
||||||
|
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||||
|
<span class="ch-marquee-content">
|
||||||
<span class="ch-title">{store.activeManga?.title}</span>
|
<span class="ch-title">{store.activeManga?.title}</span>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-sep">/</span>
|
||||||
<span class="ch-name">{displayChapter?.name}</span>
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
</span>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if !isVertical}
|
||||||
|
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if chapterHover && isVertical}
|
{#if chapterHover && isVertical}
|
||||||
<div class="ch-popover ch-popover-{popoverSide}">
|
<div class="ch-popover ch-popover-{popoverSide}">
|
||||||
@@ -170,7 +176,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isVertical}
|
{#if !isVertical}
|
||||||
<span class="bar-sep"></span>
|
<span class="bar-sep" data-tauri-drag-region></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,6 +186,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<div class="bar-drag-gap" data-tauri-drag-region></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="bar-end">
|
<div class="bar-end">
|
||||||
<div class="zoom-wrap">
|
<div class="zoom-wrap">
|
||||||
<div class="zoom-inline">
|
<div class="zoom-inline">
|
||||||
@@ -306,6 +316,7 @@
|
|||||||
{#if readerState.winOpen}
|
{#if readerState.winOpen}
|
||||||
<div
|
<div
|
||||||
class="wc-clip wc-clip-{popoverSide}"
|
class="wc-clip wc-clip-{popoverSide}"
|
||||||
|
role="presentation"
|
||||||
onmouseenter={wcResetTimer}
|
onmouseenter={wcResetTimer}
|
||||||
onmousemove={wcResetTimer}
|
onmousemove={wcResetTimer}
|
||||||
>
|
>
|
||||||
@@ -359,6 +370,7 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: opacity 0.25s ease;
|
transition: opacity 0.25s ease;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.bar.hidden { opacity: 0; pointer-events: none; }
|
.bar.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
@@ -384,12 +396,15 @@
|
|||||||
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
||||||
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
|
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
|
||||||
|
.bar-drag-gap:active { cursor: grabbing; }
|
||||||
|
|
||||||
.bar-start, .bar-end {
|
.bar-start, .bar-end {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
.bar-top .bar-start { flex: 1; overflow: hidden; }
|
.bar-top .bar-start { overflow: hidden; }
|
||||||
.bar-left .bar-start,
|
.bar-left .bar-start,
|
||||||
.bar-left .bar-end,
|
.bar-left .bar-end,
|
||||||
.bar-right .bar-start,
|
.bar-right .bar-start,
|
||||||
@@ -403,16 +418,14 @@
|
|||||||
.icon-btn.active { color: var(--accent-fg); }
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||||
|
|
||||||
.ch-hover-wrap { position: relative; min-width: 0; }
|
.ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.ch-pill {
|
.ch-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
@@ -428,9 +441,24 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
.ch-marquee-track {
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||||
|
.ch-marquee-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ch-name { color: var(--text-muted); }
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
.ch-popover {
|
.ch-popover {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div>
|
<div class="backdrop" role="button" tabindex="-1" aria-label="Close settings" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()} transition:fade={{ duration: 150 }}></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="panel"
|
class="panel"
|
||||||
@@ -130,12 +130,13 @@
|
|||||||
<p class="section-label">Page Style</p>
|
<p class="section-label">Page Style</p>
|
||||||
<div class="option-grid">
|
<div class="option-grid">
|
||||||
{#each styleOptions as o}
|
{#each styleOptions as o}
|
||||||
|
{@const Icon = o.icon}
|
||||||
<button
|
<button
|
||||||
class="option-tile"
|
class="option-tile"
|
||||||
class:active={style === o.value}
|
class:active={style === o.value}
|
||||||
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
|
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
|
||||||
>
|
>
|
||||||
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div>
|
<div class="tile-icon"><Icon size={18} weight={style === o.value ? "fill" : "light"} /></div>
|
||||||
<span class="tile-label">{o.label}</span>
|
<span class="tile-label">{o.label}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -149,6 +150,7 @@
|
|||||||
class:on={effectiveSettings.offsetDoubleSpreads}
|
class:on={effectiveSettings.offsetDoubleSpreads}
|
||||||
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
|
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Offset double spreads"
|
||||||
aria-checked={effectiveSettings.offsetDoubleSpreads}
|
aria-checked={effectiveSettings.offsetDoubleSpreads}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -161,6 +163,7 @@
|
|||||||
class:on={effectiveSettings.pageGap ?? true}
|
class:on={effectiveSettings.pageGap ?? true}
|
||||||
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
|
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Gap between pages"
|
||||||
aria-checked={effectiveSettings.pageGap ?? true}
|
aria-checked={effectiveSettings.pageGap ?? true}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -171,9 +174,36 @@
|
|||||||
class:on={store.settings.autoNextChapter ?? false}
|
class:on={store.settings.autoNextChapter ?? false}
|
||||||
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
|
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Auto next chapter"
|
||||||
aria-checked={store.settings.autoNextChapter ?? false}
|
aria-checked={store.settings.autoNextChapter ?? false}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -181,12 +211,13 @@
|
|||||||
<p class="section-label">Fit Mode</p>
|
<p class="section-label">Fit Mode</p>
|
||||||
<div class="option-grid">
|
<div class="option-grid">
|
||||||
{#each fitOptions as o}
|
{#each fitOptions as o}
|
||||||
|
{@const Icon = o.icon}
|
||||||
<button
|
<button
|
||||||
class="option-tile"
|
class="option-tile"
|
||||||
class:active={fit === o.value}
|
class:active={fit === o.value}
|
||||||
onclick={() => onApplySettings({ fitMode: o.value })}
|
onclick={() => onApplySettings({ fitMode: o.value })}
|
||||||
>
|
>
|
||||||
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div>
|
<div class="tile-icon"><Icon size={18} weight={fit === o.value ? "fill" : "light"} /></div>
|
||||||
<span class="tile-label">{o.label}</span>
|
<span class="tile-label">{o.label}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -240,7 +271,7 @@
|
|||||||
<span class="zoom-readout">{zoomPct}%</span>
|
<span class="zoom-readout">{zoomPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="zoom-row">
|
<div class="zoom-row">
|
||||||
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}>−</button>
|
<button class="zoom-step" aria-label="Zoom out" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}>−</button>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
class="zoom-slider"
|
class="zoom-slider"
|
||||||
@@ -250,7 +281,7 @@
|
|||||||
value={zoomPct}
|
value={zoomPct}
|
||||||
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
|
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
|
||||||
/>
|
/>
|
||||||
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
|
<button class="zoom-step" aria-label="Zoom in" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -263,6 +294,7 @@
|
|||||||
class:on={effectiveSettings.optimizeContrast}
|
class:on={effectiveSettings.optimizeContrast}
|
||||||
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
|
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Optimize contrast"
|
||||||
aria-checked={effectiveSettings.optimizeContrast}
|
aria-checked={effectiveSettings.optimizeContrast}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -273,6 +305,7 @@
|
|||||||
class:on={store.settings.pinchZoom ?? false}
|
class:on={store.settings.pinchZoom ?? false}
|
||||||
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
|
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Pinch to zoom"
|
||||||
aria-checked={store.settings.pinchZoom ?? false}
|
aria-checked={store.settings.pinchZoom ?? false}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -283,6 +316,7 @@
|
|||||||
class:on={store.settings.markReadOnNext ?? true}
|
class:on={store.settings.markReadOnNext ?? true}
|
||||||
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
|
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Mark read on chapter advance"
|
||||||
aria-checked={store.settings.markReadOnNext ?? true}
|
aria-checked={store.settings.markReadOnNext ?? true}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -297,6 +331,7 @@
|
|||||||
class:on={perMangaEnabled}
|
class:on={perMangaEnabled}
|
||||||
onclick={onTogglePerManga}
|
onclick={onTogglePerManga}
|
||||||
role="switch"
|
role="switch"
|
||||||
|
aria-label="Per-manga settings"
|
||||||
aria-checked={perMangaEnabled}
|
aria-checked={perMangaEnabled}
|
||||||
><span class="toggle-knob"></span></button>
|
><span class="toggle-knob"></span></button>
|
||||||
</label>
|
</label>
|
||||||
@@ -319,8 +354,8 @@
|
|||||||
bind:value={presetNameInput}
|
bind:value={presetNameInput}
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
|
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
|
||||||
/>
|
/>
|
||||||
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
|
<button class="small-btn" aria-label="Confirm" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
|
||||||
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
|
<button class="small-btn" aria-label="Cancel" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -336,8 +371,8 @@
|
|||||||
bind:value={presetEditName}
|
bind:value={presetEditName}
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
|
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
|
||||||
/>
|
/>
|
||||||
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
|
<button class="small-btn" aria-label="Confirm" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
|
||||||
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
|
<button class="small-btn" aria-label="Cancel" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preset-row">
|
<div class="preset-row">
|
||||||
@@ -751,4 +786,28 @@
|
|||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-2) 0;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user