mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-15 02:09:57 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e54338aff7 | |||
| 88f43b22c7 | |||
| 9824d31fe7 | |||
| 4fc96d873d | |||
| df9755ddf2 | |||
| ab61e12153 | |||
| 3747497041 | |||
| bbf7092d9f | |||
| b1bc3c81f9 | |||
| d6ea1fab67 | |||
| bf19ee02bc | |||
| 09d794da96 | |||
| baece20f46 | |||
| b170a151f0 | |||
| 6a84280db0 | |||
| be38d87bec | |||
| ab9305e6ab | |||
| ceb9ba12d7 | |||
| 2fa33bc928 |
@@ -1,11 +1,15 @@
|
|||||||
# Sourced by CI jobs that need versions from nix/versions.nix.
|
# Sourced by CI jobs that need versions from nix/versions.nix.
|
||||||
# Usage: source .github/read_versions.sh
|
# Usage: source .github/read_versions.sh
|
||||||
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
||||||
|
#
|
||||||
|
# Uses only POSIX -E grep (no -P) so this works on both GNU grep (Linux/Windows)
|
||||||
|
# and BSD grep (macOS), which does not support -P/PCRE.
|
||||||
|
|
||||||
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
||||||
_t=$(cat "$_nix")
|
_t=$(cat "$_nix")
|
||||||
|
|
||||||
_pick() { echo "$_t" | grep -oP "${1}\s*=\s*\"\K[^\"]+"; }
|
# Match `key = "value"` with -E, then strip the surrounding quotes.
|
||||||
|
_pick() { echo "$_t" | grep -oE "${1}"'[[:space:]]*=[[:space:]]*"[^"]+"' | grep -oE '"[^"]+"' | tr -d '"'; }
|
||||||
|
|
||||||
export MOKU_VERSION=$(_pick "moku")
|
export MOKU_VERSION=$(_pick "moku")
|
||||||
export SUWA_VERSION=$(_pick "version")
|
export SUWA_VERSION=$(_pick "version")
|
||||||
|
|||||||
@@ -22,6 +22,40 @@ jobs:
|
|||||||
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
|
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
|
||||||
sudo docker image prune -af || true
|
sudo docker image prune -af || true
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: 10 }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Build frontend and pack tarball
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:static
|
||||||
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
|
||||||
|
- name: Compute frontend-dist sha256
|
||||||
|
run: |
|
||||||
|
SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||||
|
echo "FRONTEND_SHA=$SHA" >> $GITHUB_ENV
|
||||||
|
echo "frontend-dist.tar.gz sha256: $SHA"
|
||||||
|
|
||||||
|
- name: Patch frontend-dist sha256 in flatpak manifest
|
||||||
|
run: |
|
||||||
|
python3 -c "
|
||||||
|
import re, pathlib, os
|
||||||
|
p = pathlib.Path('io.github.moku_project.Moku.yml')
|
||||||
|
content = p.read_text()
|
||||||
|
# Replace the sha256 line that follows the frontend-dist.tar.gz source entry
|
||||||
|
content = re.sub(
|
||||||
|
r'(path: packaging/frontend-dist\.tar\.gz\n\s+sha256: )[0-9a-f]{64}',
|
||||||
|
r'\g<1>' + os.environ['FRONTEND_SHA'],
|
||||||
|
content
|
||||||
|
)
|
||||||
|
p.write_text(content)
|
||||||
|
"
|
||||||
|
|
||||||
- name: Install flatpak tooling
|
- name: Install flatpak tooling
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -34,12 +68,11 @@ jobs:
|
|||||||
path: ~/.local/share/flatpak
|
path: ~/.local/share/flatpak
|
||||||
key: flatpak-runtimes-gnome48-rust-stable
|
key: flatpak-runtimes-gnome48-rust-stable
|
||||||
|
|
||||||
- name: Install runtime, SDK and rust-stable extension
|
- name: Install runtime and SDK
|
||||||
run: |
|
run: |
|
||||||
flatpak --user install -y --noninteractive flathub \
|
flatpak --user install -y --noninteractive flathub \
|
||||||
org.gnome.Platform//48 \
|
org.gnome.Platform//48 \
|
||||||
org.gnome.Sdk//48 \
|
org.gnome.Sdk//48
|
||||||
org.freedesktop.Sdk.Extension.rust-stable//48
|
|
||||||
|
|
||||||
- name: Build flatpak
|
- name: Build flatpak
|
||||||
run: |
|
run: |
|
||||||
@@ -64,14 +97,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 12); do
|
# Poll for up to 10 minutes — the release is created by the Windows workflow
|
||||||
|
# which may still be building when the flatpak bundle finishes.
|
||||||
|
for i in $(seq 1 40); do
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
[ -n "$RELEASE_ID" ] && break
|
[ -n "$RELEASE_ID" ] && break
|
||||||
echo "Waiting for release... attempt $i"; sleep 15
|
echo "Waiting for release... attempt $i/40"; sleep 15
|
||||||
done
|
done
|
||||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found after polling"; exit 1; }
|
||||||
|
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with: { version: 10 }
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
with: { workspaces: src-tauri }
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with: { version: 10 }
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@@ -91,7 +91,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
|
c = json.loads(p.read_text())
|
||||||
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
|
p.write_text(json.dumps(c, indent=2))
|
||||||
|
"
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with: { version: 10 }
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with: { version: 10 }
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
with: { workspaces: src-tauri }
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with: { version: 10 }
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@@ -88,7 +88,13 @@ jobs:
|
|||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
|
c = json.loads(p.read_text())
|
||||||
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
|
p.write_text(json.dumps(c, indent=2))
|
||||||
|
"
|
||||||
|
|
||||||
- name: Delete existing draft release
|
- name: Delete existing draft release
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.10.0
|
pkgver=0.10.1
|
||||||
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')
|
||||||
@@ -25,7 +25,7 @@ source=(
|
|||||||
)
|
)
|
||||||
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
sha256sums=(
|
sha256sums=(
|
||||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
'589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275'
|
||||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
b2sums=(
|
b2sums=(
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ modules:
|
|||||||
|
|
||||||
- name: libayatana-indicator
|
- name: libayatana-indicator
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
config-opts:
|
config-opts:
|
||||||
- -DENABLE_TESTS=OFF
|
- -DENABLE_TESTS=OFF
|
||||||
- -DGSETTINGS_COMPILE=OFF
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
@@ -90,6 +93,9 @@ modules:
|
|||||||
|
|
||||||
- name: libayatana-appindicator
|
- name: libayatana-appindicator
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
config-opts:
|
config-opts:
|
||||||
- -DENABLE_TESTS=OFF
|
- -DENABLE_TESTS=OFF
|
||||||
- -DENABLE_BINDINGS_MONO=OFF
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
@@ -232,6 +238,7 @@ modules:
|
|||||||
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:/app/lib/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
|
TAURI_CONFIG: '{"build":{"devUrl":null,"frontendDist":"../dist"},"app":{"windows":[{"devtools":false}]},"bundle":{"externalBin":[]}}'
|
||||||
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:/app/lib/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
|
||||||
@@ -244,11 +251,10 @@ 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.10.0
|
commit: baece20f467d2c7d4cebaa9ea8892980aa93aa10
|
||||||
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
|
sha256: 951d3442354ffa1b6c4109349b2a5054e52f03cf75638b2d454e937341bc93ba
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
moku = "0.10.0";
|
moku = "0.10.1";
|
||||||
|
|
||||||
suwayomi = {
|
suwayomi = {
|
||||||
version = "2.2.2196";
|
version = "2.2.2196";
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
frontend = {
|
frontend = {
|
||||||
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
pnpmHash = "sha256-fBkNpQXEeGZNbrpx7+0xVYYtQ6dGvpgRflCGPoxvnVY=";
|
||||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||||
distHashSri = "sha256-Z27CJz/9mmkkiEnF1R3E1ZpdW2j7unpP5+e1cqXyXxQ=";
|
distHashSri = "sha256-lR00QjVP+htsQQk0mypQVOUvA891Y4stRU6Tc0G8k7o=";
|
||||||
};
|
};
|
||||||
|
|
||||||
gitDeps = {
|
gitDeps = {
|
||||||
@@ -21,5 +21,5 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||||
tarballHash = "";
|
tarballHash = "589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,14 +47,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/alloc-stdlib/alloc-stdlib-0.2.2.crate",
|
"url": "https://static.crates.io/crates/alloc-stdlib/alloc-stdlib-0.2.4.crate",
|
||||||
"sha256": "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece",
|
"sha256": "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195",
|
||||||
"dest": "cargo/vendor/alloc-stdlib-0.2.2"
|
"dest": "cargo/vendor/alloc-stdlib-0.2.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece\", \"files\": {}}",
|
"contents": "{\"package\": \"0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/alloc-stdlib-0.2.2",
|
"dest": "cargo/vendor/alloc-stdlib-0.2.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -242,27 +242,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/brotli/brotli-8.0.3.crate",
|
"url": "https://static.crates.io/crates/brotli/brotli-8.0.4.crate",
|
||||||
"sha256": "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610",
|
"sha256": "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3",
|
||||||
"dest": "cargo/vendor/brotli-8.0.3"
|
"dest": "cargo/vendor/brotli-8.0.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610\", \"files\": {}}",
|
"contents": "{\"package\": \"5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/brotli-8.0.3",
|
"dest": "cargo/vendor/brotli-8.0.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.1.crate",
|
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.3.crate",
|
||||||
"sha256": "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924",
|
"sha256": "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583",
|
||||||
"dest": "cargo/vendor/brotli-decompressor-5.0.1"
|
"dest": "cargo/vendor/brotli-decompressor-5.0.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924\", \"files\": {}}",
|
"contents": "{\"package\": \"3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/brotli-decompressor-5.0.1",
|
"dest": "cargo/vendor/brotli-decompressor-5.0.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4992,14 +4992,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/time/time-0.3.48.crate",
|
"url": "https://static.crates.io/crates/time/time-0.3.49.crate",
|
||||||
"sha256": "fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2",
|
"sha256": "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469",
|
||||||
"dest": "cargo/vendor/time-0.3.48"
|
"dest": "cargo/vendor/time-0.3.49"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2\", \"files\": {}}",
|
"contents": "{\"package\": \"711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/time-0.3.48",
|
"dest": "cargo/vendor/time-0.3.49",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5018,14 +5018,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/time-macros/time-macros-0.2.28.crate",
|
"url": "https://static.crates.io/crates/time-macros/time-macros-0.2.29.crate",
|
||||||
"sha256": "9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695",
|
"sha256": "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d",
|
||||||
"dest": "cargo/vendor/time-macros-0.2.28"
|
"dest": "cargo/vendor/time-macros-0.2.29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695\", \"files\": {}}",
|
"contents": "{\"package\": \"71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/time-macros-0.2.28",
|
"dest": "cargo/vendor/time-macros-0.2.29",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+11
-11
@@ -25,9 +25,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alloc-stdlib"
|
name = "alloc-stdlib"
|
||||||
version = "0.2.2"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
@@ -144,9 +144,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.3"
|
version = "8.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -155,9 +155,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "5.0.1"
|
version = "5.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -2003,7 +2003,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -4249,9 +4249,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.48"
|
version = "0.3.49"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2"
|
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
@@ -4269,9 +4269,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.28"
|
version = "0.2.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695"
|
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -81,20 +81,3 @@ pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
|||||||
let _ = std::fs::create_dir_all(&dir);
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
dir.to_string_lossy().into_owned()
|
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()
|
|
||||||
}
|
|
||||||
@@ -107,7 +107,6 @@ pub fn run() {
|
|||||||
commands::backup::import_app_data,
|
commands::backup::import_app_data,
|
||||||
commands::backup::auto_backup_app_data,
|
commands::backup::auto_backup_app_data,
|
||||||
commands::backup::get_auto_backup_dir,
|
commands::backup::get_auto_backup_dir,
|
||||||
commands::backup::read_store_files,
|
|
||||||
commands::storage::load_store,
|
commands::storage::load_store,
|
||||||
commands::storage::save_store,
|
commands::storage::save_store,
|
||||||
commands::storage::store_credential,
|
commands::storage::store_credential,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"identifier": "io.github.MokuProject.Moku",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build:static"
|
"beforeBuildCommand": "pnpm build:static"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
+8
-16
@@ -9,15 +9,6 @@ import { historyState } from '$lib/state/history.svelt
|
|||||||
import { readerState } from '$lib/state/reader.svelte'
|
import { readerState } from '$lib/state/reader.svelte'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
|
|
||||||
const KEY_URL = 'moku_server_url'
|
|
||||||
const KEY_AUTH = 'moku_auth_config'
|
|
||||||
|
|
||||||
interface SavedAuth {
|
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
|
||||||
user?: string
|
|
||||||
pass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
try {
|
try {
|
||||||
const platformAdapter = detectAdapter()
|
const platformAdapter = detectAdapter()
|
||||||
@@ -43,20 +34,21 @@ async function boot() {
|
|||||||
readerState.markers = libraryData.markers
|
readerState.markers = libraryData.markers
|
||||||
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
||||||
|
|
||||||
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
|
const savedUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
||||||
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
|
const authMode = settingsState.settings.serverAuthMode ?? 'NONE'
|
||||||
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
|
const authUser = settingsState.settings.serverAuthUser || undefined
|
||||||
|
const authPass = settingsState.settings.serverAuthPass || undefined
|
||||||
|
|
||||||
appState.serverUrl = savedUrl
|
appState.serverUrl = savedUrl
|
||||||
appState.authMode = savedAuth.mode
|
appState.authMode = authMode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : authMode
|
||||||
|
|
||||||
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
configureAuth(savedUrl, authMode, authUser, authPass)
|
||||||
|
|
||||||
await serverAdapter.connect({
|
await serverAdapter.connect({
|
||||||
baseUrl: savedUrl,
|
baseUrl: savedUrl,
|
||||||
credentials:
|
credentials:
|
||||||
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass
|
authMode === 'BASIC_AUTH' && authUser && authPass
|
||||||
? { username: savedAuth.user, password: savedAuth.pass }
|
? { username: authUser, password: authPass }
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||||
|
|
||||||
function handleBypass() {
|
function handleBypass() {
|
||||||
bypassBoot(appState.authMode, boot.loginUser)
|
bypassBoot(appState.authMode, boot.loginUser, boot.loginPass)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if appState.status === 'auth'}
|
{#if appState.authRequired && !authVerifiedState.value}
|
||||||
<div class="overlay overlay--clear">
|
<div class="overlay overlay--clear">
|
||||||
<div class="card anim-scale-in">
|
<div class="card anim-scale-in">
|
||||||
<img src={logoUrl} alt="Moku" class="logo" />
|
<img src={logoUrl} alt="Moku" class="logo" />
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
ringFull?: boolean
|
ringFull?: boolean
|
||||||
failed?: boolean
|
failed?: boolean
|
||||||
notConfigured?: boolean
|
notConfigured?: boolean
|
||||||
|
authRequired?: boolean
|
||||||
showCards?: boolean
|
showCards?: boolean
|
||||||
showFps?: boolean
|
showFps?: boolean
|
||||||
showDevOverlay?: boolean
|
showDevOverlay?: boolean
|
||||||
@@ -56,14 +57,15 @@
|
|||||||
onUnlock?: () => void
|
onUnlock?: () => void
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
onBypass?: () => void
|
onBypass?: () => void
|
||||||
|
onSkip?: () => void
|
||||||
onDismiss?: () => void
|
onDismiss?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
mode = 'loading', ringFull = false, failed = false,
|
mode = 'loading', ringFull = false, failed = false,
|
||||||
notConfigured = false, showCards = true, showFps = false, showDevOverlay = false,
|
notConfigured = false, authRequired = false, showCards = true, showFps = false, showDevOverlay = false,
|
||||||
pinLen = 4, pinCorrect = '',
|
pinLen = 4, pinCorrect = '',
|
||||||
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
onReady, onUnlock, onRetry, onBypass, onSkip, onDismiss,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
||||||
@@ -129,7 +131,9 @@
|
|||||||
cancelAnimationFrame(animFrame)
|
cancelAnimationFrame(animFrame)
|
||||||
animFrame = 0
|
animFrame = 0
|
||||||
ringProg = 1
|
ringProg = 1
|
||||||
setTimeout(() => triggerExit(onReady), 650)
|
if (authRequired) return
|
||||||
|
const t = setTimeout(() => triggerExit(onReady), 650)
|
||||||
|
return () => clearTimeout(t)
|
||||||
})
|
})
|
||||||
|
|
||||||
function submitPin() {
|
function submitPin() {
|
||||||
@@ -506,9 +510,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else if isTauri || failed || notConfigured || ringFull}
|
||||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||||
{#if !failed && !notConfigured}
|
{#if !failed && !notConfigured && isTauri}
|
||||||
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
|
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||||
@@ -529,6 +533,13 @@
|
|||||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if authRequired && ringFull}
|
||||||
|
<div class="error-box anim-fade-up">
|
||||||
|
<p class="error-label">Waiting for login</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button class="err-btn" onclick={() => { onSkip?.() }}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
|
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
|
||||||
|
|
||||||
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
||||||
const cols = $derived(settingsState.settings.libraryCols ?? 5);
|
const cols = $derived(settingsState.settings.libraryPageSize ?? 5);
|
||||||
const cropCovers = $derived(settingsState.settings.cropCovers ?? true);
|
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true);
|
||||||
const statsAlways = $derived(settingsState.settings.statsAlways ?? false);
|
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? 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 });
|
||||||
@@ -337,14 +337,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="list">
|
<div class="list">
|
||||||
{#if showLocal}
|
{#if showLocal}
|
||||||
<div class="local-row" style="cursor:pointer" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}>
|
<button type="button" class="local-row" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}>
|
||||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">Local Source</span>
|
<span class="name">Local Source</span>
|
||||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
<span class="meta">Built-in · {localMangaCount} {localMangaCount === "1" ? "manga" : "mangas"}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="local-badge">Built-in</span>
|
<span class="local-badge">Built-in</span>
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each groups as { base, primary, variants }}
|
{#each groups as { base, primary, variants }}
|
||||||
<ExtensionCard
|
<ExtensionCard
|
||||||
@@ -404,8 +404,7 @@
|
|||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||||
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
.local-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); margin-bottom: 1px; }
|
.local-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); margin-bottom: 1px; width: 100%; text-align: left; background: none; font: inherit; cursor: pointer; }
|
||||||
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
.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; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export interface LibraryManga {
|
|||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
source: { id: string; displayName: string };
|
source: { id: string; displayName: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceLibrary {
|
export interface SourceLibrary {
|
||||||
@@ -31,7 +31,7 @@ export function libraryByExtension(
|
|||||||
const bySource = new Map<string, LibraryManga[]>();
|
const bySource = new Map<string, LibraryManga[]>();
|
||||||
for (const src of pkgSources) bySource.set(src.id, []);
|
for (const src of pkgSources) bySource.set(src.id, []);
|
||||||
for (const m of libraryManga) {
|
for (const m of libraryManga) {
|
||||||
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
if (m.source && sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pkgSources
|
return pkgSources
|
||||||
@@ -49,6 +49,7 @@ export function libraryCountByPkg(
|
|||||||
}
|
}
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const m of libraryManga) {
|
for (const m of libraryManga) {
|
||||||
|
if (!m.source) continue;
|
||||||
const pkg = sourceIdToPkg.get(m.source.id);
|
const pkg = sourceIdToPkg.get(m.source.id);
|
||||||
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
const entries = $derived(
|
const entries = $derived(
|
||||||
historyState.sessions
|
historyState.sessions
|
||||||
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
||||||
.slice(0, 10)
|
.slice(0, 5)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doRemove(m: Manga) {
|
async function doRemove(m: Manga) {
|
||||||
|
// Remove from every category first, then remove from library
|
||||||
|
const catIds = libraryState.categories
|
||||||
|
.filter(c => (libraryState.categoryMangaMap.get(c.id) ?? []).some(x => x.id === m.id))
|
||||||
|
.map(c => c.id)
|
||||||
|
if (catIds.length) {
|
||||||
|
try {
|
||||||
|
await getAdapter().updateMangaCategories(String(m.id), [], catIds)
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
await getAdapter().removeFromLibrary(String(m.id))
|
await getAdapter().removeFromLibrary(String(m.id))
|
||||||
libraryState.items = libraryState.items.filter(x => x.id !== m.id)
|
libraryState.items = libraryState.items.filter(x => x.id !== m.id)
|
||||||
await loadCategories()
|
await loadCategories()
|
||||||
@@ -193,11 +202,35 @@
|
|||||||
finally { bulkWorking = false; libraryState.exitSelect() }
|
finally { bulkWorking = false; libraryState.exitSelect() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bulkRemoveFromFolder() {
|
||||||
|
const catId = Number(libraryState.tab)
|
||||||
|
if (Number.isNaN(catId)) return
|
||||||
|
bulkWorking = true
|
||||||
|
try {
|
||||||
|
await getAdapter().updateMangasCategories(
|
||||||
|
[...libraryState.selected].map(String),
|
||||||
|
[],
|
||||||
|
[catId],
|
||||||
|
)
|
||||||
|
await loadCategories()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { bulkWorking = false; libraryState.exitSelect() }
|
||||||
|
}
|
||||||
|
|
||||||
async function onBulkRemove() {
|
async function onBulkRemove() {
|
||||||
bulkWorking = true
|
bulkWorking = true
|
||||||
try {
|
try {
|
||||||
|
// For each selected manga, remove from all its categories first
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
[...libraryState.selected].map(id => getAdapter().removeFromLibrary(String(id)))
|
[...libraryState.selected].map(async (id) => {
|
||||||
|
const catIds = libraryState.categories
|
||||||
|
.filter(c => (libraryState.categoryMangaMap.get(c.id) ?? []).some(x => x.id === id))
|
||||||
|
.map(c => c.id)
|
||||||
|
if (catIds.length) {
|
||||||
|
try { await getAdapter().updateMangaCategories(String(id), [], catIds) } catch {}
|
||||||
|
}
|
||||||
|
return getAdapter().removeFromLibrary(String(id))
|
||||||
|
})
|
||||||
)
|
)
|
||||||
libraryState.items = libraryState.items.filter(m => !libraryState.selected.has(m.id))
|
libraryState.items = libraryState.items.filter(m => !libraryState.selected.has(m.id))
|
||||||
libraryState.exitSelect()
|
libraryState.exitSelect()
|
||||||
@@ -451,6 +484,7 @@
|
|||||||
onSelectAll={() => libraryState.selectAll(libraryState.filteredItems.map(m => m.id))}
|
onSelectAll={() => libraryState.selectAll(libraryState.filteredItems.map(m => m.id))}
|
||||||
onExitSelect={() => libraryState.exitSelect()}
|
onExitSelect={() => libraryState.exitSelect()}
|
||||||
onBulkRemove={onBulkRemove}
|
onBulkRemove={onBulkRemove}
|
||||||
|
onBulkRemoveFromFolder={bulkRemoveFromFolder}
|
||||||
onBulkMove={bulkMove}
|
onBulkMove={bulkMove}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
import { CheckSquare, Trash, Folder, FolderPlus, FolderMinus } from 'phosphor-svelte'
|
||||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import { settingsState } from '$lib/state/settings.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import type { Manga, Category } from '$lib/types'
|
import type { Manga, Category } from '$lib/types'
|
||||||
@@ -17,20 +17,49 @@
|
|||||||
onSelectAll: () => void
|
onSelectAll: () => void
|
||||||
onExitSelect: () => void
|
onExitSelect: () => void
|
||||||
onBulkRemove: () => void
|
onBulkRemove: () => void
|
||||||
|
onBulkRemoveFromFolder: () => void
|
||||||
onBulkMove: (cat: Category) => void
|
onBulkMove: (cat: Category) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items, loading, selectMode, selected, tab,
|
items, loading, selectMode, selected, tab,
|
||||||
visibleCategories, bulkWorking,
|
visibleCategories, bulkWorking,
|
||||||
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
onCardClick, onCardContextMenu, onSelectAll, onExitSelect,
|
||||||
|
onBulkRemove, onBulkRemoveFromFolder, onBulkMove,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
|
const isFolderTab = $derived(tab !== 'library' && tab !== 'downloaded')
|
||||||
|
|
||||||
let movePanelOpen = $state(false)
|
let movePanelOpen = $state(false)
|
||||||
|
|
||||||
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
|
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
|
||||||
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
|
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
|
||||||
|
|
||||||
|
const PAGE = 48
|
||||||
|
let visibleCount = $state(PAGE)
|
||||||
|
let sentinel: HTMLDivElement | undefined = $state()
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
const renderedItems = $derived(items.slice(0, visibleCount))
|
||||||
|
const hasMore = $derived(visibleCount < items.length)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
items
|
||||||
|
visibleCount = PAGE
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
if (!sentinel) return
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0]?.isIntersecting && hasMore) {
|
||||||
|
visibleCount = Math.min(visibleCount + PAGE, items.length)
|
||||||
|
}
|
||||||
|
}, { rootMargin: '200px' })
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
function onDocDown(e: MouseEvent) {
|
function onDocDown(e: MouseEvent) {
|
||||||
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
||||||
}
|
}
|
||||||
@@ -49,12 +78,12 @@
|
|||||||
{#if visibleCategories.length > 0}
|
{#if visibleCategories.length > 0}
|
||||||
<div class="move-wrap">
|
<div class="move-wrap">
|
||||||
<button
|
<button
|
||||||
class="sel-action-btn"
|
class="sel-icon-btn"
|
||||||
|
title="Move to folder"
|
||||||
disabled={selected.size === 0 || bulkWorking}
|
disabled={selected.size === 0 || bulkWorking}
|
||||||
onclick={() => movePanelOpen = !movePanelOpen}
|
onclick={() => movePanelOpen = !movePanelOpen}
|
||||||
>
|
>
|
||||||
<Folder size={13} weight="bold" />
|
<FolderPlus size={14} weight="bold" />
|
||||||
Move to folder
|
|
||||||
</button>
|
</button>
|
||||||
{#if movePanelOpen}
|
{#if movePanelOpen}
|
||||||
<div class="move-panel" role="menu">
|
<div class="move-panel" role="menu">
|
||||||
@@ -73,13 +102,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isFolderTab}
|
||||||
<button
|
<button
|
||||||
class="sel-action-btn sel-danger"
|
class="sel-icon-btn"
|
||||||
|
title="Remove from folder"
|
||||||
|
disabled={selected.size === 0 || bulkWorking}
|
||||||
|
onclick={onBulkRemoveFromFolder}
|
||||||
|
>
|
||||||
|
<FolderMinus size={14} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sel-icon-btn sel-icon-danger"
|
||||||
|
title="Remove from library"
|
||||||
disabled={selected.size === 0 || bulkWorking}
|
disabled={selected.size === 0 || bulkWorking}
|
||||||
onclick={onBulkRemove}
|
onclick={onBulkRemove}
|
||||||
>
|
>
|
||||||
<Trash size={13} weight="bold" />
|
<Trash size={14} weight="bold" />
|
||||||
Remove
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +151,7 @@
|
|||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each items as m (m.id)}
|
{#each renderedItems as m (m.id)}
|
||||||
{@const isSelected = selected.has(m.id)}
|
{@const isSelected = selected.has(m.id)}
|
||||||
{@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)}
|
{@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)}
|
||||||
<button
|
<button
|
||||||
@@ -152,6 +192,9 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if hasMore}
|
||||||
|
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,30 +224,31 @@
|
|||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
.sel-text-btn:hover { color: var(--text-primary); }
|
.sel-text-btn:hover { color: var(--text-primary); }
|
||||||
.sel-action-btn {
|
.sel-icon-btn {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
padding: 5px 10px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
color: var(--text-muted); cursor: pointer; white-space: nowrap;
|
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
.sel-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
.sel-icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.sel-danger:hover:not(:disabled) {
|
.sel-icon-danger:hover:not(:disabled) {
|
||||||
color: var(--color-error, #e05c5c);
|
color: var(--color-error, #e05c5c);
|
||||||
border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent);
|
border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent);
|
||||||
background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent);
|
background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.move-wrap { position: relative; }
|
.move-wrap { position: relative; }
|
||||||
|
|
||||||
.move-panel {
|
.move-panel {
|
||||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999;
|
|
||||||
min-width: 180px; background: var(--bg-raised);
|
min-width: 180px; background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-base); border-radius: var(--radius-lg);
|
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);
|
padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
animation: fadeIn 0.1s ease both;
|
animation: fadeIn 0.1s ease both;
|
||||||
|
position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.move-item {
|
.move-item {
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
|
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
@@ -289,6 +333,8 @@
|
|||||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||||
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
|
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
|
||||||
|
|
||||||
|
.sentinel { height: 1px; width: 100%; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
height: 60%; color: var(--text-muted); font-size: var(--text-sm);
|
height: 60%; color: var(--text-muted); font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
|
import { canOpenFolder } from "$lib/core/filesystem";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
import type { Category } from "$lib/types";
|
import type { Category } from "$lib/types";
|
||||||
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
|
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
|
||||||
@@ -62,7 +63,14 @@
|
|||||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let wheelTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onTabsWheel(e: WheelEvent) {
|
function onTabsWheel(e: WheelEvent) {
|
||||||
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
||||||
|
e.preventDefault()
|
||||||
|
tabsEl?.scrollBy({ left: e.deltaY * 0.5, behavior: "instant" })
|
||||||
|
if (wheelTimer) return
|
||||||
|
wheelTimer = setTimeout(() => { wheelTimer = null }, 180)
|
||||||
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
||||||
const idx = ids.indexOf(tab);
|
const idx = ids.indexOf(tab);
|
||||||
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
||||||
@@ -165,9 +173,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if canOpenFolder()}
|
||||||
<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" />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sort-panel-wrap">
|
<div class="sort-panel-wrap">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -221,8 +221,11 @@
|
|||||||
try {
|
try {
|
||||||
if (updaterRunning) {
|
if (updaterRunning) {
|
||||||
await getAdapter().stopLibraryUpdate()
|
await getAdapter().stopLibraryUpdate()
|
||||||
|
updaterRunning = false
|
||||||
|
stopStatusPolling()
|
||||||
} else {
|
} else {
|
||||||
await getAdapter().startLibraryUpdate()
|
await getAdapter().startLibraryUpdate()
|
||||||
|
updaterRunning = true
|
||||||
scheduleStatusPoll()
|
scheduleStatusPoll()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -239,11 +242,13 @@
|
|||||||
{historyConfirmClear}
|
{historyConfirmClear}
|
||||||
hasHistory={historyState.sessions.length > 0}
|
hasHistory={historyState.sessions.length > 0}
|
||||||
{updatesLoading}
|
{updatesLoading}
|
||||||
|
{updaterRunning}
|
||||||
onTabChange={(t) => tab = t}
|
onTabChange={(t) => tab = t}
|
||||||
onHistorySearchChange={(v) => historySearch = v}
|
onHistorySearchChange={(v) => historySearch = v}
|
||||||
onUpdatesSearchChange={(v) => updatesSearch = v}
|
onUpdatesSearchChange={(v) => updatesSearch = v}
|
||||||
onHistoryClear={handleHistoryClear}
|
onHistoryClear={handleHistoryClear}
|
||||||
onRefreshUpdates={() => loadUpdates(true)}
|
onRefreshUpdates={() => loadUpdates(true)}
|
||||||
|
onToggleUpdate={toggleLibraryUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
ArrowsClockwise, BookOpen, CircleNotch,
|
ArrowsClockwise, BookOpen, CircleNotch,
|
||||||
MagnifyingGlass, NewspaperClipping, Trash,
|
MagnifyingGlass, NewspaperClipping, Trash, X,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,18 +11,20 @@
|
|||||||
historyConfirmClear: boolean
|
historyConfirmClear: boolean
|
||||||
hasHistory: boolean
|
hasHistory: boolean
|
||||||
updatesLoading: boolean
|
updatesLoading: boolean
|
||||||
|
updaterRunning: boolean
|
||||||
onTabChange: (tab: 'updates' | 'history') => void
|
onTabChange: (tab: 'updates' | 'history') => void
|
||||||
onHistorySearchChange: (v: string) => void
|
onHistorySearchChange: (v: string) => void
|
||||||
onUpdatesSearchChange: (v: string) => void
|
onUpdatesSearchChange: (v: string) => void
|
||||||
onHistoryClear: () => void
|
onHistoryClear: () => void
|
||||||
onRefreshUpdates: () => void
|
onRefreshUpdates: () => void
|
||||||
|
onToggleUpdate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
||||||
updatesLoading,
|
updatesLoading, updaterRunning,
|
||||||
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
||||||
onHistoryClear, onRefreshUpdates,
|
onHistoryClear, onRefreshUpdates, onToggleUpdate,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,12 +59,15 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
onclick={onRefreshUpdates}
|
class:running={updaterRunning}
|
||||||
disabled={updatesLoading}
|
onclick={updaterRunning ? onToggleUpdate : onRefreshUpdates}
|
||||||
title="Reload update list"
|
disabled={updatesLoading && !updaterRunning}
|
||||||
|
title={updaterRunning ? 'Stop library update' : 'Run library update'}
|
||||||
>
|
>
|
||||||
{#if updatesLoading}
|
{#if updatesLoading && !updaterRunning}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if updaterRunning}
|
||||||
|
<X size={14} weight="bold" />
|
||||||
{:else}
|
{:else}
|
||||||
<ArrowsClockwise size={14} weight="bold" />
|
<ArrowsClockwise size={14} weight="bold" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BookOpen, CircleNotch, Download, Trash } from 'phosphor-svelte'
|
import { BookOpen, CircleNotch, Download, Trash, CaretDown, CaretRight } from 'phosphor-svelte'
|
||||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||||
|
|
||||||
|
const BUNDLE_THRESHOLD = 3
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -26,6 +28,44 @@
|
|||||||
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
|
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
|
// key = `${dayLabel}::${mangaId}`, tracks which bundles are expanded
|
||||||
|
let expandedBundles: Record<string, boolean> = $state({})
|
||||||
|
|
||||||
|
function bundleKey(dayLabel: string, mangaId: number) {
|
||||||
|
return `${dayLabel}::${mangaId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBundle(key: string) {
|
||||||
|
expandedBundles = { ...expandedBundles, [key]: !expandedBundles[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleRow = { kind: 'single'; item: RecentUpdate }
|
||||||
|
type BundleRow = { kind: 'bundle'; mangaId: number; items: RecentUpdate[]; key: string }
|
||||||
|
type Row = SingleRow | BundleRow
|
||||||
|
|
||||||
|
// Within a day group, collapse consecutive runs of BUNDLE_THRESHOLD+ chapters from the same manga
|
||||||
|
function bundleRows(dayLabel: string, items: RecentUpdate[]): Row[] {
|
||||||
|
const rows: Row[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < items.length) {
|
||||||
|
const cur = items[i]
|
||||||
|
const mangaId = cur.mangaId ?? cur.manga?.id
|
||||||
|
if (mangaId == null) { rows.push({ kind: 'single', item: cur }); i++; continue }
|
||||||
|
|
||||||
|
let j = i + 1
|
||||||
|
while (j < items.length && (items[j].mangaId ?? items[j].manga?.id) === mangaId) j++
|
||||||
|
|
||||||
|
const run = items.slice(i, j)
|
||||||
|
if (run.length >= BUNDLE_THRESHOLD) {
|
||||||
|
rows.push({ kind: 'bundle', mangaId, items: run, key: bundleKey(dayLabel, mangaId) })
|
||||||
|
} else {
|
||||||
|
for (const item of run) rows.push({ kind: 'single', item })
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
const filteredGroups = $derived(updatesSearch.trim()
|
const filteredGroups = $derived(updatesSearch.trim()
|
||||||
? groups
|
? groups
|
||||||
.map(g => ({
|
.map(g => ({
|
||||||
@@ -43,6 +83,16 @@
|
|||||||
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`
|
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`
|
||||||
return 'Chapter'
|
return 'Chapter'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bundleChapterRange(items: RecentUpdate[]): string {
|
||||||
|
const nums = items
|
||||||
|
.map(i => i.chapterNumber)
|
||||||
|
.filter(n => Number.isFinite(n)) as number[]
|
||||||
|
if (!nums.length) return `${items.length} chapters`
|
||||||
|
const min = Math.min(...nums)
|
||||||
|
const max = Math.max(...nums)
|
||||||
|
return min === max ? `Ch. ${min}` : `Ch. ${min}–${max}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
@@ -124,7 +174,10 @@
|
|||||||
<div class="day-rule"></div>
|
<div class="day-rule"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="updates-list">
|
<div class="updates-list">
|
||||||
{#each items as item (item.id)}
|
{#each bundleRows(label, items) as row (row.kind === 'single' ? row.item.id : row.key)}
|
||||||
|
|
||||||
|
{#if row.kind === 'single'}
|
||||||
|
{@const item = row.item}
|
||||||
<div class="update-row" class:read={item.isRead}>
|
<div class="update-row" class:read={item.isRead}>
|
||||||
<button class="thumb-btn" onclick={() => onOpenSeries(item)} title="View series">
|
<button class="thumb-btn" onclick={() => onOpenSeries(item)} title="View series">
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
@@ -168,6 +221,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{@const bundle = row}
|
||||||
|
{@const expanded = expandedBundles[bundle.key] ?? false}
|
||||||
|
{@const first = bundle.items[0]}
|
||||||
|
{@const hasUnread = bundle.items.some(i => !i.isRead)}
|
||||||
|
<div class="bundle" class:expanded>
|
||||||
|
<!-- collapsed header -->
|
||||||
|
<div class="bundle-header" class:read={!hasUnread}>
|
||||||
|
<button class="thumb-btn" onclick={() => onOpenSeries(first)} title="View series">
|
||||||
|
<Thumbnail
|
||||||
|
src={first.manga?.thumbnailUrl ?? ''}
|
||||||
|
alt={first.manga?.title ?? 'Series cover'}
|
||||||
|
class="thumb"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button class="bundle-summary" onclick={() => toggleBundle(bundle.key)}>
|
||||||
|
<div class="update-info">
|
||||||
|
<div class="title-row">
|
||||||
|
<span class="series-title">{first.manga?.title ?? 'Unknown series'}</span>
|
||||||
|
{#if hasUnread}<span class="pill" title="Unread"></span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="chapter-title">{bundleChapterRange(bundle.items)}</span>
|
||||||
|
<div class="meta-row"><span>{bundle.items.length} chapters</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row-end">
|
||||||
|
<span class="caret">
|
||||||
|
{#if expanded}
|
||||||
|
<CaretDown size={13} weight="bold" />
|
||||||
|
{:else}
|
||||||
|
<CaretRight size={13} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- expanded chapter list -->
|
||||||
|
{#if expanded}
|
||||||
|
<div class="bundle-items">
|
||||||
|
{#each bundle.items as item (item.id)}
|
||||||
|
<div class="update-row bundle-child" class:read={item.isRead}>
|
||||||
|
<button
|
||||||
|
class="info-btn"
|
||||||
|
onclick={() => onOpenUpdate(item)}
|
||||||
|
disabled={openingId === item.id}
|
||||||
|
>
|
||||||
|
<div class="update-info">
|
||||||
|
<div class="title-row">
|
||||||
|
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||||
|
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||||
|
<div class="meta-row"><span>Resume p.{item.lastPageRead}</span></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="row-end">
|
||||||
|
{#if enqueueing.has(item.id)}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if item.isDownloaded}
|
||||||
|
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); onDeleteDownload(item) }} title="Delete download">
|
||||||
|
<Trash size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); onEnqueue(item) }} title="Download">
|
||||||
|
<Download size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if openingId === item.id}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<BookOpen size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -287,6 +422,48 @@
|
|||||||
.dl-btn-delete { color: var(--color-error); }
|
.dl-btn-delete { color: var(--color-error); }
|
||||||
.dl-btn-delete:hover { background: var(--color-error-bg); }
|
.dl-btn-delete:hover { background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
/* ── Bundle styles ── */
|
||||||
|
.bundle {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.bundle.expanded { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.bundle-header {
|
||||||
|
display: flex; align-items: stretch;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.bundle-header.read { opacity: 0.5; }
|
||||||
|
.bundle-header:has(.bundle-summary:hover) { background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.bundle-summary {
|
||||||
|
flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-3); background: none; border: none;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret { color: var(--text-faint); display: flex; align-items: center; }
|
||||||
|
|
||||||
|
.bundle-items {
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundle-child {
|
||||||
|
border-radius: 0; border: none;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay, var(--bg-elevated));
|
||||||
|
padding-left: var(--sp-6);
|
||||||
|
}
|
||||||
|
.bundle-child:last-child { border-bottom: none; }
|
||||||
|
.bundle-child:has(.info-btn:hover:not(:disabled)) {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
justify-content: center; gap: var(--sp-2); color: var(--text-faint);
|
justify-content: center; gap: var(--sp-2); color: var(--text-faint);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
|
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
|
||||||
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen,
|
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
|
import { canOpenFolder } from '$lib/core/filesystem'
|
||||||
import type { Chapter, Category } from '$lib/types'
|
import type { Chapter, Category } from '$lib/types'
|
||||||
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
||||||
|
|
||||||
@@ -275,7 +276,7 @@
|
|||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if downloadedCount > 0}
|
{#if downloadedCount > 0 && canOpenFolder()}
|
||||||
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
||||||
<FolderOpen size={14} weight="light" />
|
<FolderOpen size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
||||||
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
||||||
|
import { selectPortal } from '$lib/core/ui/selectPortal'
|
||||||
|
|
||||||
import GeneralSettings from './sections/GeneralSettings.svelte'
|
import GeneralSettings from './sections/GeneralSettings.svelte'
|
||||||
import AppearanceSettings from './sections/AppearanceSettings.svelte'
|
import AppearanceSettings from './sections/AppearanceSettings.svelte'
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
let tabSlideDir = $state<'up'|'down'>('down')
|
let tabSlideDir = $state<'up'|'down'>('down')
|
||||||
let tabIconKey = $state(0)
|
let tabIconKey = $state(0)
|
||||||
let contentBodyEl: HTMLDivElement
|
let contentBodyEl: HTMLDivElement
|
||||||
|
let modalEl: HTMLDivElement
|
||||||
let bugReporterOpen = $state(false)
|
let bugReporterOpen = $state(false)
|
||||||
|
|
||||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
let selectOpen: string | null = $state(null)
|
let selectOpen: string | null = $state(null)
|
||||||
let closingSelect: string | null = $state(null)
|
let closingSelect: string | null = $state(null)
|
||||||
const CLOSE_ANIM_MS = 120
|
const CLOSE_ANIM_MS = 120
|
||||||
|
const selectTriggers = new Map<string, HTMLElement>()
|
||||||
|
|
||||||
function closeSelect() {
|
function closeSelect() {
|
||||||
if (!selectOpen) return
|
if (!selectOpen) return
|
||||||
@@ -102,6 +105,14 @@
|
|||||||
else { closingSelect = null; selectOpen = id }
|
else { closingSelect = null; selectOpen = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerTrigger(id: string, el: HTMLElement) {
|
||||||
|
selectTriggers.set(id, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrigger(id: string): HTMLElement | undefined {
|
||||||
|
return selectTriggers.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (!selectOpen) return
|
if (!selectOpen) return
|
||||||
@@ -118,7 +129,7 @@
|
|||||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
||||||
<div class="s-modal" role="dialog" aria-label="Settings">
|
<div class="s-modal" role="dialog" aria-label="Settings" bind:this={modalEl}>
|
||||||
|
|
||||||
<div class="s-sidebar">
|
<div class="s-sidebar">
|
||||||
<p class="s-sidebar-title">Settings</p>
|
<p class="s-sidebar-title">Settings</p>
|
||||||
@@ -164,13 +175,13 @@
|
|||||||
|
|
||||||
<div class="s-content-body" bind:this={contentBodyEl}>
|
<div class="s-content-body" bind:this={contentBodyEl}>
|
||||||
{#if tab === 'general'}
|
{#if tab === 'general'}
|
||||||
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} {anims} />
|
||||||
{:else if tab === 'appearance'}
|
{:else if tab === 'appearance'}
|
||||||
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
|
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} {anims} {onOpenThemeEditor} />
|
||||||
{:else if tab === 'reader'}
|
{:else if tab === 'reader'}
|
||||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} {anims} />
|
||||||
{:else if tab === 'library'}
|
{:else if tab === 'library'}
|
||||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} {anims} />
|
||||||
{:else if tab === 'automation'}
|
{:else if tab === 'automation'}
|
||||||
<AutomationSettings />
|
<AutomationSettings />
|
||||||
{:else if tab === 'performance'}
|
{:else if tab === 'performance'}
|
||||||
@@ -178,13 +189,13 @@
|
|||||||
{:else if tab === 'keybinds'}
|
{:else if tab === 'keybinds'}
|
||||||
<KeybindsSettings bind:listeningKey />
|
<KeybindsSettings bind:listeningKey />
|
||||||
{:else if tab === 'storage'}
|
{:else if tab === 'storage'}
|
||||||
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
|
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} />
|
||||||
{:else if tab === 'folders'}
|
{:else if tab === 'folders'}
|
||||||
<FoldersSettings />
|
<FoldersSettings />
|
||||||
{:else if tab === 'tracking'}
|
{:else if tab === 'tracking'}
|
||||||
<TrackingSettings />
|
<TrackingSettings />
|
||||||
{:else if tab === 'security'}
|
{:else if tab === 'security'}
|
||||||
<SecuritySettings {selectOpen} {toggleSelect} />
|
<SecuritySettings {selectOpen} {toggleSelect} {registerTrigger} {getTrigger} {selectPortal} {modalEl} />
|
||||||
{:else if tab === 'content'}
|
{:else if tab === 'content'}
|
||||||
<ContentSettings />
|
<ContentSettings />
|
||||||
{:else if tab === 'about'}
|
{:else if tab === 'about'}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from 'phosphor-svelte'
|
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from 'phosphor-svelte'
|
||||||
import { getAdapter } from '$lib/request-manager'
|
import { getAdapter } from '$lib/request-manager'
|
||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
import type { Category } from '$lib/types'
|
import type { Category } from '$lib/types'
|
||||||
|
|
||||||
let categories = $state<Category[]>([])
|
let categories = $state<Category[]>([])
|
||||||
@@ -206,7 +207,7 @@
|
|||||||
<DotsSixVertical size={14} weight="bold" />
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
||||||
<span class="s-folder-count">{cat?.mangas?.length ?? 0} manga</span>
|
<span class="s-folder-count">{libraryState.counts[String(cat?.id)] ?? 0} manga</span>
|
||||||
<span class="s-folder-badge">built-in</span>
|
<span class="s-folder-badge">built-in</span>
|
||||||
<div class="s-folder-actions">
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<button class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</button>
|
<button class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="s-folder-count">{cat.mangas?.length ?? 0} manga</span>
|
<span class="s-folder-count">{libraryState.counts[String(cat.id)] ?? 0} manga</span>
|
||||||
<div class="s-folder-actions">
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon"
|
<button class="s-btn-icon"
|
||||||
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||||
|
|||||||
@@ -4,15 +4,22 @@
|
|||||||
|
|
||||||
const isTauri = platformService.platform === 'tauri'
|
const isTauri = platformService.platform === 'tauri'
|
||||||
|
|
||||||
|
import { selectPortal as _defaultPortal } from '$lib/core/ui/selectPortal'
|
||||||
|
import type { Action } from 'svelte/action'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null
|
selectOpen: string | null
|
||||||
closingSelect: string | null
|
closingSelect: string | null
|
||||||
toggleSelect: (id: string) => void
|
toggleSelect: (id: string) => void
|
||||||
|
registerTrigger: (id: string, el: HTMLElement) => void
|
||||||
|
getTrigger: (id: string) => HTMLElement | undefined
|
||||||
|
selectPortal: Action<HTMLElement, HTMLElement | undefined>
|
||||||
anims: boolean
|
anims: boolean
|
||||||
}
|
}
|
||||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props()
|
let { selectOpen, closingSelect, toggleSelect, registerTrigger, getTrigger, selectPortal, anims }: Props = $props()
|
||||||
|
|
||||||
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
|
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
|
||||||
|
$effect(() => { if (triggerIdleTimeout) registerTrigger('idle-timeout', triggerIdleTimeout) })
|
||||||
let serverAdvancedOpen = $state(false)
|
let serverAdvancedOpen = $state(false)
|
||||||
|
|
||||||
async function pickServerBinary() {
|
async function pickServerBinary() {
|
||||||
@@ -138,7 +145,7 @@
|
|||||||
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
|
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
|
||||||
<div class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
|
<div use:selectPortal={getTrigger('idle-timeout')} class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
|
||||||
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
|
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
|
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { homeState } from '$lib/state/home.svelte'
|
import { homeState } from '$lib/state/home.svelte'
|
||||||
import type { Settings } from '$lib/types/settings'
|
import type { Settings } from '$lib/types/settings'
|
||||||
|
import type { Action } from 'svelte/action'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null
|
selectOpen: string | null
|
||||||
closingSelect?: string | null
|
closingSelect?: string | null
|
||||||
toggleSelect: (id: string) => void
|
toggleSelect: (id: string) => void
|
||||||
|
registerTrigger: (id: string, el: HTMLElement) => void
|
||||||
|
getTrigger: (id: string) => HTMLElement | undefined
|
||||||
|
selectPortal: Action<HTMLElement, HTMLElement | undefined>
|
||||||
anims: boolean
|
anims: boolean
|
||||||
}
|
}
|
||||||
let { selectOpen, toggleSelect, anims }: Props = $props()
|
let { selectOpen, closingSelect, toggleSelect, registerTrigger, getTrigger, selectPortal, anims }: Props = $props()
|
||||||
|
|
||||||
let triggerSortDir = $state<HTMLButtonElement>(null!)
|
let triggerSortDir = $state<HTMLButtonElement>(null!)
|
||||||
|
|
||||||
|
$effect(() => { if (triggerSortDir) registerTrigger('sort-dir', triggerSortDir) })
|
||||||
|
|
||||||
function clearHistory() {
|
function clearHistory() {
|
||||||
homeState.history = []
|
homeState.history = []
|
||||||
}
|
}
|
||||||
@@ -54,8 +60,8 @@
|
|||||||
<span>{{ 'desc':'Newest first','asc':'Oldest first' }[settingsState.settings.chapterSortDir]}</span>
|
<span>{{ 'desc':'Newest first','asc':'Oldest first' }[settingsState.settings.chapterSortDir]}</span>
|
||||||
<svg class="s-select-caret" class:open={selectOpen === 'sort-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'sort-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'sort-dir'}
|
{#if selectOpen === 'sort-dir' || closingSelect === 'sort-dir'}
|
||||||
<div class="s-select-menu" class:anims>
|
<div use:selectPortal={getTrigger('sort-dir')} class="s-select-menu" class:anims class:closing={closingSelect === 'sort-dir'}>
|
||||||
{#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]}
|
{#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={settingsState.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings['chapterSortDir'] }); toggleSelect('sort-dir') }}>{l}</button>
|
<button class="s-select-option" class:active={settingsState.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings['chapterSortDir'] }); toggleSelect('sort-dir') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import type { Settings, FitMode } from '$lib/types/settings'
|
import type { Settings, FitMode } from '$lib/types/settings'
|
||||||
|
import type { Action } from 'svelte/action'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null
|
selectOpen: string | null
|
||||||
closingSelect?: string | null
|
closingSelect?: string | null
|
||||||
toggleSelect: (id: string) => void
|
toggleSelect: (id: string) => void
|
||||||
|
registerTrigger: (id: string, el: HTMLElement) => void
|
||||||
|
getTrigger: (id: string) => HTMLElement | undefined
|
||||||
|
selectPortal: Action<HTMLElement, HTMLElement | undefined>
|
||||||
anims: boolean
|
anims: boolean
|
||||||
}
|
}
|
||||||
let { selectOpen, toggleSelect, anims }: Props = $props()
|
let { selectOpen, closingSelect, toggleSelect, registerTrigger, getTrigger, selectPortal, anims }: Props = $props()
|
||||||
|
|
||||||
let triggerPageStyle = $state<HTMLButtonElement>(null!)
|
let triggerPageStyle = $state<HTMLButtonElement>(null!)
|
||||||
let triggerReadingDir = $state<HTMLButtonElement>(null!)
|
let triggerReadingDir = $state<HTMLButtonElement>(null!)
|
||||||
let triggerFitMode = $state<HTMLButtonElement>(null!)
|
let triggerFitMode = $state<HTMLButtonElement>(null!)
|
||||||
|
|
||||||
|
$effect(() => { if (triggerPageStyle) registerTrigger('page-style', triggerPageStyle) })
|
||||||
|
$effect(() => { if (triggerReadingDir) registerTrigger('reading-dir', triggerReadingDir) })
|
||||||
|
$effect(() => { if (triggerFitMode) registerTrigger('fit-mode', triggerFitMode) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
@@ -27,8 +35,8 @@
|
|||||||
<span>{{ 'single':'Single page','longstrip':'Long strip' }[settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle]}</span>
|
<span>{{ 'single':'Single page','longstrip':'Long strip' }[settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle]}</span>
|
||||||
<svg class="s-select-caret" class:open={selectOpen === 'page-style'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'page-style'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'page-style'}
|
{#if selectOpen === 'page-style' || closingSelect === 'page-style'}
|
||||||
<div class="s-select-menu" class:anims>
|
<div use:selectPortal={getTrigger('page-style')} class="s-select-menu" class:anims class:closing={closingSelect === 'page-style'}>
|
||||||
{#each [['single','Single page'],['longstrip','Long strip']] as [v, l]}
|
{#each [['single','Single page'],['longstrip','Long strip']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={(settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings['pageStyle'] }); toggleSelect('page-style') }}>{l}</button>
|
<button class="s-select-option" class:active={(settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings['pageStyle'] }); toggleSelect('page-style') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -43,8 +51,8 @@
|
|||||||
<span>{{ 'ltr':'Left to right','rtl':'Right to left' }[settingsState.settings.readingDirection]}</span>
|
<span>{{ 'ltr':'Left to right','rtl':'Right to left' }[settingsState.settings.readingDirection]}</span>
|
||||||
<svg class="s-select-caret" class:open={selectOpen === 'reading-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'reading-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'reading-dir'}
|
{#if selectOpen === 'reading-dir' || closingSelect === 'reading-dir'}
|
||||||
<div class="s-select-menu" class:anims>
|
<div use:selectPortal={getTrigger('reading-dir')} class="s-select-menu" class:anims class:closing={closingSelect === 'reading-dir'}>
|
||||||
{#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]}
|
{#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={settingsState.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings['readingDirection'] }); toggleSelect('reading-dir') }}>{l}</button>
|
<button class="s-select-option" class:active={settingsState.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings['readingDirection'] }); toggleSelect('reading-dir') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -81,8 +89,8 @@
|
|||||||
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
|
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
|
||||||
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'fit-mode'}
|
{#if selectOpen === 'fit-mode' || closingSelect === 'fit-mode'}
|
||||||
<div class="s-select-menu" class:anims>
|
<div use:selectPortal={getTrigger('fit-mode')} class="s-select-menu" class:anims class:closing={closingSelect === 'fit-mode'}>
|
||||||
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
|
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
|
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -137,7 +137,12 @@
|
|||||||
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
|
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
|
||||||
step = "confirm";
|
step = "confirm";
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (/no chapters found/i.test(e.message)) {
|
||||||
|
selectedMatch = { manga: m, chapters: [], readCount: 0, similarity };
|
||||||
|
step = "confirm";
|
||||||
|
} else {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingMatchId = null;
|
loadingMatchId = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { getBlobUrl } from "$lib/core/cache/imageCache";
|
import { getBlobUrl } from "$lib/core/cache/imageCache";
|
||||||
|
import { appState } from "$lib/state/app.svelte";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
src,
|
src,
|
||||||
@@ -17,8 +20,8 @@
|
|||||||
id?: string | number;
|
id?: string | number;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
loading?: string;
|
loading?: "lazy" | "eager";
|
||||||
decoding?: string;
|
decoding?: "async" | "auto" | "sync";
|
||||||
priority?: number;
|
priority?: number;
|
||||||
onerror?: ((e: Event) => void) | undefined;
|
onerror?: ((e: Event) => void) | undefined;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
return withBust(base);
|
return withBust(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
const isAuth = $derived(appState.authMode !== "NONE");
|
||||||
|
|
||||||
let blobUrl = $state("");
|
let blobUrl = $state("");
|
||||||
let reqId = 0;
|
let reqId = 0;
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const plainUrl = $derived(plainThumbUrl(src));
|
const plainUrl = $derived(plainThumbUrl(src));
|
||||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
const resolved = $derived(isAuth ? (blobUrl || undefined) : (plainUrl || undefined));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||||
+51
-18
@@ -1,3 +1,7 @@
|
|||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
|
import { LOGIN_MUTATION, REFRESH_MUTATION } from '$lib/server-adapters/suwayomi/meta'
|
||||||
|
|
||||||
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
||||||
const SKEW_MS = 60_000 * 2
|
const SKEW_MS = 60_000 * 2
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ let accessToken: string | null = null
|
|||||||
let refreshToken: string | null = null
|
let refreshToken: string | null = null
|
||||||
let accessExpiresAt: number | null = null
|
let accessExpiresAt: number | null = null
|
||||||
let refreshInFlight = false
|
let refreshInFlight = false
|
||||||
|
let authSnoozed = false
|
||||||
|
|
||||||
function parseExpiry(token: string): number | null {
|
function parseExpiry(token: string): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -56,6 +61,22 @@ export function getUiAuthDebugStatus(): UiAuthDebugStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reportUnauthorized(): void {
|
||||||
|
if (config.mode === 'NONE') return
|
||||||
|
if (authSnoozed) return
|
||||||
|
appState.authRequired = true
|
||||||
|
authVerifiedState.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportAuthOk(): void {
|
||||||
|
appState.authRequired = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snoozeAuthPrompt(): void {
|
||||||
|
authSnoozed = true
|
||||||
|
appState.authRequired = false
|
||||||
|
}
|
||||||
|
|
||||||
export function configureAuth(
|
export function configureAuth(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||||
@@ -66,6 +87,8 @@ export function configureAuth(
|
|||||||
accessToken = null
|
accessToken = null
|
||||||
refreshToken = null
|
refreshToken = null
|
||||||
accessExpiresAt = null
|
accessExpiresAt = null
|
||||||
|
authSnoozed = false
|
||||||
|
appState.authRequired = false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authHeaders(): Record<string, string> {
|
export function authHeaders(): Record<string, string> {
|
||||||
@@ -86,9 +109,16 @@ async function gql<T>(query: string, variables?: Record<string, unknown>, bare =
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
reportUnauthorized()
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
return json.data as T
|
return json.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,34 +139,33 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
|||||||
} catch { return 'unreachable' }
|
} catch { return 'unreachable' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGIN_MUTATION = `
|
export function loginBasic(user: string, pass: string): void {
|
||||||
mutation Login($username: String!, $password: String!) {
|
config.user = user
|
||||||
login(input: { username: $username, password: $password }) {
|
config.pass = pass
|
||||||
accessToken refreshToken
|
config.mode = 'BASIC_AUTH'
|
||||||
|
authSnoozed = false
|
||||||
|
reportAuthOk()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const REFRESH_MUTATION = `
|
/**
|
||||||
mutation RefreshToken($refreshToken: String!) {
|
* Verify basic-auth credentials by making a real GQL request with them.
|
||||||
refreshToken(input: { refreshToken: $refreshToken }) {
|
* Throws if the server returns 401/403 or an auth error.
|
||||||
accessToken
|
*/
|
||||||
}
|
export async function verifyBasicAuth(user: string, pass: string): Promise<void> {
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
|
||||||
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
||||||
config.user = user
|
config.user = user
|
||||||
config.pass = pass
|
config.pass = pass
|
||||||
config.mode = 'BASIC_AUTH'
|
config.mode = 'BASIC_AUTH'
|
||||||
const probe = await probeServer()
|
try {
|
||||||
if (probe !== 'ok') {
|
await gql<unknown>('{ settings { authMode } }')
|
||||||
|
} catch {
|
||||||
config.user = prev.user
|
config.user = prev.user
|
||||||
config.pass = prev.pass
|
config.pass = prev.pass
|
||||||
config.mode = prev.mode as typeof config.mode
|
config.mode = prev.mode as typeof config.mode
|
||||||
throw new Error('Invalid credentials')
|
throw new Error('Invalid credentials')
|
||||||
}
|
}
|
||||||
|
authSnoozed = false
|
||||||
|
reportAuthOk()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
@@ -148,6 +177,8 @@ export async function loginUI(user: string, pass: string): Promise<void> {
|
|||||||
accessExpiresAt = parseExpiry(accessToken)
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
config.mode = 'UI_LOGIN'
|
config.mode = 'UI_LOGIN'
|
||||||
config.user = user
|
config.user = user
|
||||||
|
authSnoozed = false
|
||||||
|
reportAuthOk()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
@@ -163,8 +194,10 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
|
|||||||
)
|
)
|
||||||
accessToken = data.refreshToken.accessToken
|
accessToken = data.refreshToken.accessToken
|
||||||
accessExpiresAt = parseExpiry(accessToken)
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
|
reportAuthOk()
|
||||||
return accessToken
|
return accessToken
|
||||||
} catch {
|
} catch {
|
||||||
|
reportUnauthorized()
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false
|
refreshInFlight = false
|
||||||
|
|||||||
+19
-24
@@ -1,24 +1,27 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
saveSettings,
|
loadSettings, saveSettings,
|
||||||
saveLibrary,
|
loadLibrary, saveLibrary,
|
||||||
saveUpdates,
|
loadUpdates, saveUpdates,
|
||||||
} from "$lib/core/persistence/persist";
|
} from "$lib/core/persistence/persist";
|
||||||
|
|
||||||
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
|
async function collectStoreFiles(): Promise<{ name: string; bytes: Uint8Array }[]> {
|
||||||
|
const [settings, library, updates] = await Promise.all([
|
||||||
|
loadSettings(),
|
||||||
|
loadLibrary(),
|
||||||
|
loadUpdates(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
return [
|
||||||
|
{ name: "settings.json", bytes: enc.encode(JSON.stringify(settings)) },
|
||||||
|
{ name: "library.json", bytes: enc.encode(JSON.stringify(library)) },
|
||||||
|
{ name: "updates.json", bytes: enc.encode(JSON.stringify(updates)) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportAppData(): Promise<void> {
|
export async function exportAppData(): Promise<void> {
|
||||||
const entries: [string, string][] = await invoke("read_store_files", {
|
const zip = buildZip(await collectStoreFiles());
|
||||||
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) });
|
await invoke("export_app_data", { bytes: Array.from(zip) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +63,7 @@ export async function importAppData(): Promise<void> {
|
|||||||
|
|
||||||
export async function autoBackupAppData(): Promise<void> {
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const entries: [string, string][] = await invoke("read_store_files", {
|
const zip = buildZip(await collectStoreFiles());
|
||||||
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) });
|
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);
|
||||||
|
|||||||
Vendored
+2
-17
@@ -1,6 +1,5 @@
|
|||||||
import { platformService } from "$lib/platform-service";
|
import { platformService } from "$lib/platform-service";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { authHeaders } from "$lib/core/auth";
|
||||||
import { getUIAccessToken } from "$lib/core/auth";
|
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
@@ -18,22 +17,8 @@ interface QueueEntry {
|
|||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "UI_LOGIN") {
|
|
||||||
const token = getUIAccessToken();
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doFetch(url: string, gen: number): Promise<string> {
|
async function doFetch(url: string, gen: number): Promise<string> {
|
||||||
const headers = await getAuthHeaders();
|
const headers = authHeaders();
|
||||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
const blob = await platformService.fetchImage(url, headers);
|
const blob = await platformService.fetchImage(url, headers);
|
||||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
|||||||
Vendored
+2
-7
@@ -1,4 +1,5 @@
|
|||||||
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
||||||
|
import { authHeaders } from "$lib/core/auth";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
@@ -12,13 +13,7 @@ function getServerUrl(): string {
|
|||||||
|
|
||||||
async function fetchChapterPagesFromServer(chapterId: number): Promise<string[]> {
|
async function fetchChapterPagesFromServer(chapterId: number): Promise<string[]> {
|
||||||
const base = getServerUrl();
|
const base = getServerUrl();
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json", ...authHeaders() };
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
const query = `mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } }`;
|
const query = `mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } }`;
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -16,11 +16,28 @@ function join(root: string, ...parts: string[]): string {
|
|||||||
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSupported(): boolean {
|
function isLocalServer(): boolean {
|
||||||
|
try {
|
||||||
|
const host = new URL(settingsState.settings.serverUrl).hostname
|
||||||
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenFolder(): boolean {
|
||||||
|
return platformService.isSupported('filesystem') && isLocalServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCanOpenFolder(): boolean {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!platformService.isSupported('filesystem')) {
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (!isLocalServer()) {
|
||||||
|
addToast({ kind: 'info', title: 'Remote server', body: 'Folder access is unavailable when connected to a remote server.' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +50,7 @@ function checkRoot(root: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openMangaFolder(manga: Manga): Promise<void> {
|
export async function openMangaFolder(manga: Manga): Promise<void> {
|
||||||
if (!checkSupported()) return
|
if (!checkCanOpenFolder()) return
|
||||||
const root = getDownloadsRoot()
|
const root = getDownloadsRoot()
|
||||||
if (!checkRoot(root)) return
|
if (!checkRoot(root)) return
|
||||||
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
|
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
|
||||||
@@ -44,14 +61,14 @@ export async function openMangaFolder(manga: Manga): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openDownloadsFolder(): Promise<void> {
|
export async function openDownloadsFolder(): Promise<void> {
|
||||||
if (!checkSupported()) return
|
if (!checkCanOpenFolder()) return
|
||||||
const root = getDownloadsRoot()
|
const root = getDownloadsRoot()
|
||||||
if (!checkRoot(root)) return
|
if (!checkRoot(root)) return
|
||||||
await platformService.openPath(root).catch(console.error)
|
await platformService.openPath(root).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCustomFolder(path: string): Promise<void> {
|
export async function openCustomFolder(path: string): Promise<void> {
|
||||||
if (!checkSupported()) return
|
if (!checkCanOpenFolder()) return
|
||||||
if (!path?.trim()) return
|
if (!path?.trim()) return
|
||||||
await platformService.openPath(path).catch(console.error)
|
await platformService.openPath(path).catch(console.error)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export function selectPortal(
|
||||||
|
node: HTMLElement,
|
||||||
|
trigger: HTMLElement | undefined,
|
||||||
|
): { update(t: HTMLElement | undefined): void; destroy(): void } {
|
||||||
|
let currentTrigger = trigger
|
||||||
|
|
||||||
|
node.style.position = 'fixed'
|
||||||
|
node.style.visibility = 'hidden'
|
||||||
|
node.style.zIndex = '99999'
|
||||||
|
document.body.appendChild(node)
|
||||||
|
|
||||||
|
function getZoom(): number {
|
||||||
|
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
|
||||||
|
return raw > 10 ? raw / 100 : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!currentTrigger) return
|
||||||
|
|
||||||
|
const zoom = getZoom()
|
||||||
|
const r = currentTrigger.getBoundingClientRect()
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
const menuH = node.offsetHeight
|
||||||
|
const menuW = node.offsetWidth
|
||||||
|
|
||||||
|
const above = menuH > 0 && (vh - r.bottom) < menuH + 8 && r.top > menuH + 8
|
||||||
|
const cssLeft = Math.max(4, r.left + menuW > vw ? r.right - menuW : r.left) / zoom
|
||||||
|
const cssTop = (above ? r.top - menuH - 4 : r.bottom + 4) / zoom
|
||||||
|
|
||||||
|
node.style.left = `${cssLeft}px`
|
||||||
|
node.style.top = `${cssTop}px`
|
||||||
|
node.style.minWidth = `${r.width / zoom}px`
|
||||||
|
node.style.visibility = 'visible'
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => position())
|
||||||
|
|
||||||
|
window.addEventListener('scroll', position, { capture: true, passive: true })
|
||||||
|
window.addEventListener('resize', position, { passive: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(t) {
|
||||||
|
currentTrigger = t
|
||||||
|
node.style.visibility = 'hidden'
|
||||||
|
requestAnimationFrame(() => position())
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('scroll', position, true)
|
||||||
|
window.removeEventListener('resize', position)
|
||||||
|
if (document.body.contains(node)) node.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ import {
|
|||||||
RESTORE_BACKUP,
|
RESTORE_BACKUP,
|
||||||
VALIDATE_BACKUP,
|
VALIDATE_BACKUP,
|
||||||
} from './meta'
|
} from './meta'
|
||||||
import { authHeaders } from '$lib/core/auth'
|
import { authHeaders, reportUnauthorized } from '$lib/core/auth'
|
||||||
import {
|
import {
|
||||||
type GQLResponse,
|
type GQLResponse,
|
||||||
mapManga,
|
mapManga,
|
||||||
@@ -181,12 +181,40 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
reportUnauthorized()
|
||||||
|
throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||||
const json: GQLResponse<T> = await res.json()
|
const json: GQLResponse<T> = await res.json()
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
return json.data
|
return json.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private multipartGql<T>(query: string, file: File): Promise<T> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
||||||
|
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||||
|
form.append('0', file, file.name)
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
||||||
|
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
||||||
|
.then(r => {
|
||||||
|
if (r.status === 401 || r.status === 403) { reportUnauthorized(); throw new Error(`Suwayomi HTTP ${r.status}`) }
|
||||||
|
if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then((json: GQLResponse<T>) => {
|
||||||
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
|
return json.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async getAboutServer(): Promise<AboutServer> {
|
async getAboutServer(): Promise<AboutServer> {
|
||||||
const data = await this.gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER)
|
const data = await this.gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER)
|
||||||
return data.aboutServer
|
return data.aboutServer
|
||||||
@@ -502,7 +530,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
ids: number[],
|
ids: number[],
|
||||||
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
|
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Suwayomi has no bulk-category-patch mutation; fan out individually.
|
|
||||||
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
|
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,17 +673,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return data.createBackup
|
return data.createBackup
|
||||||
}
|
}
|
||||||
|
|
||||||
private multipartGql<T>(query: string, file: File): Promise<T> {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
|
||||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
|
||||||
form.append('0', file, file.name)
|
|
||||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
|
||||||
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
|
||||||
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
|
|
||||||
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||||
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
||||||
return data.restoreBackup
|
return data.restoreBackup
|
||||||
|
|||||||
@@ -103,3 +103,19 @@ export const SET_FLARE_SOLVERR = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const LOGIN_MUTATION = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const REFRESH_MUTATION = `
|
||||||
|
mutation RefreshToken($refreshToken: String!) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken }) {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Platform } from '$lib/platform-adapters/types'
|
import type { Platform } from '$lib/platform-adapters/types'
|
||||||
|
|
||||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'locked' | 'ready' | 'error'
|
export type AppStatus = 'booting' | 'not-configured' | 'locked' | 'ready' | 'error'
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
settingsOpen: boolean = $state(false)
|
settingsOpen: boolean = $state(false)
|
||||||
@@ -23,6 +23,7 @@ export const app = new AppStore()
|
|||||||
|
|
||||||
export const appState = $state({
|
export const appState = $state({
|
||||||
status: 'booting' as AppStatus,
|
status: 'booting' as AppStatus,
|
||||||
|
authRequired: false as boolean,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
@@ -35,6 +36,8 @@ export const appState = $state({
|
|||||||
history: [] as unknown[],
|
history: [] as unknown[],
|
||||||
toasts: [] as unknown[],
|
toasts: [] as unknown[],
|
||||||
appDir: '',
|
appDir: '',
|
||||||
|
authUser: '',
|
||||||
|
authPass: '',
|
||||||
idleSplash: false,
|
idleSplash: false,
|
||||||
devSplash: false,
|
devSplash: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const authVerifiedState = $state({ value: false })
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { detectAdapter } from '$lib/platform-adapters'
|
import { detectAdapter } from '$lib/platform-adapters'
|
||||||
import { initPlatformService } from '$lib/platform-service'
|
import { initPlatformService } from '$lib/platform-service'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { probeServer, loginBasic, loginUI, configureAuth } from '$lib/core/auth'
|
import { probeServer, loginBasic, loginUI, verifyBasicAuth, configureAuth } from '$lib/core/auth'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
import { settingsState } from '$lib/state/settings.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40
|
const MAX_ATTEMPTS = 40
|
||||||
|
const WEB_MAX_ATTEMPTS = 1
|
||||||
const BG_MAX_ATTEMPTS = 120
|
const BG_MAX_ATTEMPTS = 120
|
||||||
|
|
||||||
export const boot = $state({
|
export const boot = $state({
|
||||||
@@ -33,11 +35,8 @@ export async function initPlatform(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pinLockEnabled(): boolean {
|
function pinLockEnabled(): boolean {
|
||||||
return (
|
const pin = settingsState.settings.appLockPin
|
||||||
settingsState.settings.appLockEnabled === true &&
|
return typeof pin === 'string' && pin.length >= 4
|
||||||
typeof settingsState.settings.appLockPin === 'string' &&
|
|
||||||
settingsState.settings.appLockPin.length >= 4
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProbeSuccess(gen: number) {
|
function handleProbeSuccess(gen: number) {
|
||||||
@@ -45,6 +44,7 @@ function handleProbeSuccess(gen: number) {
|
|||||||
boot.failed = false
|
boot.failed = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
|
authVerifiedState.value = true
|
||||||
appState.authenticated = true
|
appState.authenticated = true
|
||||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
}
|
}
|
||||||
@@ -56,24 +56,22 @@ function handleAuthRequired(
|
|||||||
pass: string,
|
pass: string,
|
||||||
) {
|
) {
|
||||||
if (gen !== probeGeneration) return
|
if (gen !== probeGeneration) return
|
||||||
|
if (boot.skipped) return
|
||||||
boot.failed = false
|
boot.failed = false
|
||||||
appState.authMode = authMode
|
appState.authMode = authMode
|
||||||
|
|
||||||
if (authMode === 'BASIC_AUTH' && user && pass) {
|
if (authMode === 'BASIC_AUTH' && user && pass) {
|
||||||
|
// Saved creds — set optimistically; a real 401 will re-prompt via reportUnauthorized
|
||||||
loginBasic(user, pass)
|
loginBasic(user, pass)
|
||||||
.then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) })
|
handleProbeSuccess(gen)
|
||||||
.catch(() => {
|
|
||||||
if (gen !== probeGeneration) return
|
|
||||||
boot.loginUser = user
|
|
||||||
boot.loginRequired = true
|
|
||||||
appState.status = 'auth'
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
boot.loginUser = user
|
boot.loginUser = user
|
||||||
boot.loginRequired = true
|
boot.loginRequired = true
|
||||||
appState.status = 'auth'
|
authVerifiedState.value = false
|
||||||
|
appState.authRequired = true
|
||||||
|
appState.status = 'ready' // let layout render, AuthGate overlay will block
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startProbe(
|
export async function startProbe(
|
||||||
@@ -87,19 +85,13 @@ export async function startProbe(
|
|||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.serverProbeOk = false
|
boot.serverProbeOk = false
|
||||||
|
authVerifiedState.value = false
|
||||||
appState.status = 'booting'
|
appState.status = 'booting'
|
||||||
appState.authMode = authMode
|
appState.authMode = authMode
|
||||||
|
|
||||||
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
||||||
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
|
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
|
||||||
|
|
||||||
if (appState.platform === 'web') {
|
|
||||||
boot.failed = true
|
|
||||||
appState.status = 'error'
|
|
||||||
startBackgroundProbe(gen, authMode, user, pass)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let tries = 0
|
let tries = 0
|
||||||
|
|
||||||
async function probe() {
|
async function probe() {
|
||||||
@@ -110,7 +102,8 @@ export async function startProbe(
|
|||||||
|
|
||||||
if (result === 'ok') { handleProbeSuccess(gen); return }
|
if (result === 'ok') { handleProbeSuccess(gen); return }
|
||||||
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
|
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
|
||||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
|
const maxAttempts = appState.platform === 'tauri' ? MAX_ATTEMPTS : WEB_MAX_ATTEMPTS
|
||||||
|
if (tries >= maxAttempts) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
|
||||||
|
|
||||||
setTimeout(probe, Math.min(500 + tries * 200, 2000))
|
setTimeout(probe, Math.min(500 + tries * 200, 2000))
|
||||||
}
|
}
|
||||||
@@ -157,7 +150,7 @@ export async function submitLogin(): Promise<void> {
|
|||||||
if (appState.authMode === 'UI_LOGIN') {
|
if (appState.authMode === 'UI_LOGIN') {
|
||||||
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
|
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
|
||||||
} else {
|
} else {
|
||||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim())
|
await verifyBasicAuth(boot.loginUser.trim(), boot.loginPass.trim())
|
||||||
}
|
}
|
||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.sessionExpired = false
|
boot.sessionExpired = false
|
||||||
@@ -165,7 +158,9 @@ export async function submitLogin(): Promise<void> {
|
|||||||
boot.loginPass = ''
|
boot.loginPass = ''
|
||||||
boot.loginError = null
|
boot.loginError = null
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
|
authVerifiedState.value = true
|
||||||
appState.authenticated = true
|
appState.authenticated = true
|
||||||
|
appState.authRequired = false
|
||||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
||||||
@@ -191,10 +186,11 @@ export function bypassBoot(
|
|||||||
user = '',
|
user = '',
|
||||||
pass = '',
|
pass = '',
|
||||||
) {
|
) {
|
||||||
const gen = probeGeneration
|
|
||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.sessionExpired = false
|
boot.sessionExpired = false
|
||||||
boot.skipped = true
|
boot.skipped = true
|
||||||
|
authVerifiedState.value = true // user explicitly opted out of the auth gate
|
||||||
|
appState.authRequired = false
|
||||||
appState.status = 'ready'
|
appState.status = 'ready'
|
||||||
startBackgroundProbe(gen, authMode, user, pass)
|
startBackgroundProbe(probeGeneration, authMode, user, pass)
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ export interface Source {
|
|||||||
isNsfw: boolean
|
isNsfw: boolean
|
||||||
isConfigurable: boolean
|
isConfigurable: boolean
|
||||||
supportsLatest: boolean
|
supportsLatest: boolean
|
||||||
|
extension?: { pkgName: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { appState, app } from '$lib/state/app.svelte'
|
import { appState, app, type AppStatus } from '$lib/state/app.svelte'
|
||||||
import { boot } from '$lib/state/boot.svelte'
|
import { boot } from '$lib/state/boot.svelte'
|
||||||
import { notifications } from '$lib/state/notifications.svelte'
|
import { notifications } from '$lib/state/notifications.svelte'
|
||||||
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
@@ -34,22 +35,28 @@
|
|||||||
|
|
||||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
|
appState.status = 'booting' as AppStatus
|
||||||
|
|
||||||
let splashDismissed = $state(false)
|
let splashDismissed = $state(false)
|
||||||
|
let settingsLoaded = $state(false)
|
||||||
let themeEditorOpen = $state(false)
|
let themeEditorOpen = $state(false)
|
||||||
let themeEditorId = $state<string | null>(null)
|
let themeEditorId = $state<string | null>(null)
|
||||||
|
|
||||||
const splashVisible = $derived(
|
const splashVisible = $derived(
|
||||||
!splashDismissed ||
|
|
||||||
appState.status === 'booting' ||
|
appState.status === 'booting' ||
|
||||||
appState.status === 'locked' ||
|
appState.status === 'locked' ||
|
||||||
appState.status === 'error' ||
|
appState.status === 'error' ||
|
||||||
appState.status === 'auth'
|
(appState.status === 'ready' && !splashDismissed)
|
||||||
|
)
|
||||||
|
|
||||||
|
const splashMode = $derived(
|
||||||
|
appState.status === 'locked' && settingsLoaded ? 'locked' : 'loading'
|
||||||
)
|
)
|
||||||
|
|
||||||
const ringFull = $derived(appState.status === 'ready')
|
const ringFull = $derived(appState.status === 'ready')
|
||||||
const showApp = $derived(!splashVisible)
|
const showApp = $derived(!splashVisible)
|
||||||
|
|
||||||
function onSplashReady() { splashDismissed = true }
|
function onSplashReady() { if (!appState.authRequired || authVerifiedState.value) splashDismissed = true }
|
||||||
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
||||||
function onSplashBypass() {
|
function onSplashBypass() {
|
||||||
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
||||||
@@ -62,7 +69,8 @@
|
|||||||
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
||||||
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
|
async function init() {
|
||||||
const { detectAdapter } = await import('$lib/platform-adapters')
|
const { detectAdapter } = await import('$lib/platform-adapters')
|
||||||
const { initPlatformService } = await import('$lib/platform-service')
|
const { initPlatformService } = await import('$lib/platform-service')
|
||||||
const { loadSettings } = await import('$lib/core/persistence/persist')
|
const { loadSettings } = await import('$lib/core/persistence/persist')
|
||||||
@@ -85,6 +93,8 @@
|
|||||||
appState.authUser = (s.serverAuthUser as string) ?? ''
|
appState.authUser = (s.serverAuthUser as string) ?? ''
|
||||||
appState.authPass = (s.serverAuthPass as string) ?? ''
|
appState.authPass = (s.serverAuthPass as string) ?? ''
|
||||||
|
|
||||||
|
settingsLoaded = true
|
||||||
|
|
||||||
applyTheme(
|
applyTheme(
|
||||||
settingsState.settings.theme ?? 'dark',
|
settingsState.settings.theme ?? 'dark',
|
||||||
settingsState.settings.customThemes ?? [],
|
settingsState.settings.customThemes ?? [],
|
||||||
@@ -107,6 +117,9 @@
|
|||||||
|
|
||||||
polling = true
|
polling = true
|
||||||
pollLoop()
|
pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
polling = false
|
polling = false
|
||||||
@@ -191,15 +204,17 @@
|
|||||||
|
|
||||||
{#if splashVisible}
|
{#if splashVisible}
|
||||||
<SplashScreen
|
<SplashScreen
|
||||||
mode={appState.status === 'locked' ? 'locked' : 'loading'}
|
mode={splashMode}
|
||||||
{ringFull}
|
{ringFull}
|
||||||
failed={appState.status === 'error'}
|
failed={appState.status === 'error'}
|
||||||
notConfigured={boot.notConfigured}
|
notConfigured={boot.notConfigured}
|
||||||
|
authRequired={appState.authRequired && !authVerifiedState.value}
|
||||||
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
||||||
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
||||||
onReady={onSplashReady}
|
onReady={onSplashReady}
|
||||||
onUnlock={onSplashUnlock}
|
onUnlock={onSplashUnlock}
|
||||||
onBypass={onSplashBypass}
|
onBypass={onSplashBypass}
|
||||||
|
onSkip={onSplashBypass}
|
||||||
onRetry={onSplashRetry}
|
onRetry={onSplashRetry}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user