Compare commits

..

11 Commits

Author SHA1 Message Date
Youwes09 b170a151f0 Chore: Flatpak Libayatana-AppIndicator 2026-06-12 23:38:44 -05:00
Youwes09 6a84280db0 Chore: Flatpak-Workflow V2 2026-06-12 23:31:03 -05:00
Youwes09 be38d87bec Chore: Update Versions-Script for MacOS 2026-06-12 23:26:19 -05:00
Youwes09 ab9305e6ab Fix: Workflow V2 2026-06-12 22:02:38 -05:00
Youwes09 ceb9ba12d7 Fix: pnpm Version Issues 2026-06-12 18:27:57 -05:00
Youwes09 2fa33bc928 Chore: Post-Bump for v0.10.0 2026-06-12 18:20:55 -05:00
Youwes09 5c703bdba5 Chore: Bump to v0.10.0 2026-06-12 17:59:11 -05:00
Youwes09 a041b182e5 Feat: Static & Flatpak Workflow + Recent Fix 2026-06-12 17:52:09 -05:00
Youwes09 9dad1fb329 Feat: Recent Tab (Unread State) + Bug Fixes 2026-06-12 17:27:08 -05:00
Youwes09 31a19687ce Fix: Browse Bug Fixes & Enhancements 2026-06-12 04:12:33 -05:00
Youwes09 437b52fd8b Feat: Longstrip Viewer(s) & Lag Improvements 2026-06-11 23:27:01 -05:00
73 changed files with 2586 additions and 2360 deletions
+5 -1
View File
@@ -1,11 +1,15 @@
# Sourced by CI jobs that need versions from nix/versions.nix.
# 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
#
# 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"
_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 SUWA_VERSION=$(_pick "version")
+115
View File
@@ -0,0 +1,115 @@
name: Build Flatpak
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
flatpak:
name: Build Flatpak bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Free up disk space
run: |
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
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
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Cache flatpak runtimes/SDKs
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-runtimes-gnome48-rust-stable
- name: Install runtime and SDK
run: |
flatpak --user install -y --noninteractive flathub \
org.gnome.Platform//48 \
org.gnome.Sdk//48
- name: Build flatpak
run: |
rm -rf build-dir repo
flatpak-builder \
--user \
--install-deps-from=flathub \
--repo=repo \
--force-clean \
build-dir \
io.github.moku_project.Moku.yml
- name: Bundle flatpak
run: |
flatpak build-bundle \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
repo \
moku.flatpak \
io.github.moku_project.Moku
- name: Upload Flatpak artifact to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# 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" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
[ -n "$RELEASE_ID" ] && break
echo "Waiting for release... attempt $i/40"; sleep 15
done
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found after polling"; exit 1; }
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"moku.flatpak" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku.flatpak"
+9 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: latest }
with: { version: 10 }
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -52,7 +52,7 @@ jobs:
with: { workspaces: src-tauri }
- uses: pnpm/action-setup@v4
with: { version: latest }
with: { version: 10 }
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -91,7 +91,13 @@ jobs:
- name: Patch tauri.conf.json for CI
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)
run: |
+51
View File
@@ -0,0 +1,51 @@
name: Build Static WebUI
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
build:
name: Build static frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 10 }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:static
- name: Zip static build
run: |
cd dist
zip -r "../moku-webui-${{ github.event.inputs.version }}.zip" .
- name: Upload WebUI artifact to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
[ -n "$RELEASE_ID" ] && break
echo "Waiting for release... attempt $i"; sleep 15
done
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/zip" \
--data-binary @"moku-webui-${{ github.event.inputs.version }}.zip" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku-webui-${{ github.event.inputs.version }}.zip"
+9 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: latest }
with: { version: 10 }
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -51,7 +51,7 @@ jobs:
with: { workspaces: src-tauri }
- uses: pnpm/action-setup@v4
with: { version: latest }
with: { version: 10 }
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -88,7 +88,13 @@ jobs:
- name: Patch tauri.conf.json for CI
shell: bash
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
shell: bash
+2 -2
View File
@@ -1,5 +1,5 @@
pkgname=moku
pkgver=0.9.4
pkgver=0.10.0
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
@@ -25,7 +25,7 @@ source=(
)
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=(
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
+9 -3
View File
@@ -80,6 +80,9 @@ modules:
- name: libayatana-indicator
buildsystem: cmake-ninja
build-options:
env:
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
@@ -90,6 +93,9 @@ modules:
- name: libayatana-appindicator
buildsystem: cmake-ninja
build-options:
env:
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
config-opts:
- -DENABLE_TESTS=OFF
- -DENABLE_BINDINGS_MONO=OFF
@@ -244,11 +250,11 @@ modules:
sources:
- type: git
url: https://github.com/moku-project/Moku.git
tag: v0.9.4
commit: 239960683b6c7f1347e1798b0e179a8a46628728
tag: v0.10.0
commit: 5c703bdba5f61cedea90a803a5f533e805070d59
- type: file
path: packaging/frontend-dist.tar.gz
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
+16 -15
View File
@@ -7,6 +7,7 @@
gnused
coreutils
git
xxd
rustToolchain
nodejs_22
pnpm
@@ -85,30 +86,30 @@ PYEOF
if [[ $# -ge 2 ]]; then
SUWA_VER="$2"
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}"
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v''${SUWA_VER}"
echo "Fetching Suwayomi v${SUWA_VER} hashes (5 downloads)..."
echo "Fetching Suwayomi v''${SUWA_VER} hashes (5 downloads)..."
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
JAR_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}.jar")
WIN_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-windows-x64.zip")
LINUX_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-linux-x64.tar.gz")
ARM64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-arm64.tar.gz")
X64_SHA=$(sha_of "${BASE}/Suwayomi-Server-v${SUWA_VER}-macOS-x64.tar.gz")
JAR_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}.jar")
WIN_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-windows-x64.zip")
LINUX_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-linux-x64.tar.gz")
ARM64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-arm64.tar.gz")
X64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-x64.tar.gz")
JAR_SRI=$(to_sri "$JAR_SHA")
sed -i "s/version = \"[^\"]*\"/version = \"${SUWA_VER}\"/" "$VERSIONS"
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"${JAR_SRI}\"|" "$VERSIONS"
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"${WIN_SHA}\"|" "$VERSIONS"
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"${LINUX_SHA}\"|" "$VERSIONS"
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"${ARM64_SHA}\"|" "$VERSIONS"
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"${X64_SHA}\"|" "$VERSIONS"
sed -i "s/version = \"[^\"]*\"/version = \"''${SUWA_VER}\"/" "$VERSIONS"
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"''${JAR_SRI}\"|" "$VERSIONS"
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"''${WIN_SHA}\"|" "$VERSIONS"
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"''${LINUX_SHA}\"|" "$VERSIONS"
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"''${ARM64_SHA}\"|" "$VERSIONS"
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"''${X64_SHA}\"|" "$VERSIONS"
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v''${SUWA_VER}/|" "$MANIFEST"
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v''${SUWA_VER}.jar|g" "$MANIFEST"
python3 - "$MANIFEST" "$JAR_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
+4 -4
View File
@@ -1,5 +1,5 @@
{
moku = "0.9.4";
moku = "0.10.0";
suwayomi = {
version = "2.2.2196";
@@ -11,9 +11,9 @@
};
frontend = {
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
pnpmHash = "sha256-fBkNpQXEeGZNbrpx7+0xVYYtQ6dGvpgRflCGPoxvnVY=";
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
distHashSri = "sha256-Z27CJz/9mmkkiEnF1R3E1ZpdW2j7unpP5+e1cqXyXxQ=";
};
gitDeps = {
@@ -21,5 +21,5 @@
};
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
tarballHash = "";
tarballHash = "589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275";
}
+2 -1
View File
@@ -21,6 +21,8 @@
"@sveltejs/kit": "^2.62.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2.11.2",
"@types/node": "^25.9.3",
"phosphor-svelte": "^3.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.5.0",
"typescript": "^6.0.3",
@@ -42,7 +44,6 @@
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
}
}
+255 -255
View File
@@ -125,14 +125,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/autocfg/autocfg-1.5.0.crate",
"sha256": "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8",
"dest": "cargo/vendor/autocfg-1.5.0"
"url": "https://static.crates.io/crates/autocfg/autocfg-1.5.1.crate",
"sha256": "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53",
"dest": "cargo/vendor/autocfg-1.5.1"
},
{
"type": "inline",
"contents": "{\"package\": \"c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8\", \"files\": {}}",
"dest": "cargo/vendor/autocfg-1.5.0",
"contents": "{\"package\": \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\", \"files\": {}}",
"dest": "cargo/vendor/autocfg-1.5.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -203,14 +203,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/bitflags/bitflags-2.11.1.crate",
"sha256": "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3",
"dest": "cargo/vendor/bitflags-2.11.1"
"url": "https://static.crates.io/crates/bitflags/bitflags-2.13.0.crate",
"sha256": "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8",
"dest": "cargo/vendor/bitflags-2.13.0"
},
{
"type": "inline",
"contents": "{\"package\": \"c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3\", \"files\": {}}",
"dest": "cargo/vendor/bitflags-2.11.1",
"contents": "{\"package\": \"b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8\", \"files\": {}}",
"dest": "cargo/vendor/bitflags-2.13.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -242,27 +242,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/brotli/brotli-8.0.2.crate",
"sha256": "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560",
"dest": "cargo/vendor/brotli-8.0.2"
"url": "https://static.crates.io/crates/brotli/brotli-8.0.3.crate",
"sha256": "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610",
"dest": "cargo/vendor/brotli-8.0.3"
},
{
"type": "inline",
"contents": "{\"package\": \"4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560\", \"files\": {}}",
"dest": "cargo/vendor/brotli-8.0.2",
"contents": "{\"package\": \"8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610\", \"files\": {}}",
"dest": "cargo/vendor/brotli-8.0.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.0.crate",
"sha256": "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03",
"dest": "cargo/vendor/brotli-decompressor-5.0.0"
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.1.crate",
"sha256": "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924",
"dest": "cargo/vendor/brotli-decompressor-5.0.1"
},
{
"type": "inline",
"contents": "{\"package\": \"874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03\", \"files\": {}}",
"dest": "cargo/vendor/brotli-decompressor-5.0.0",
"contents": "{\"package\": \"5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924\", \"files\": {}}",
"dest": "cargo/vendor/brotli-decompressor-5.0.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -281,14 +281,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/bumpalo/bumpalo-3.20.2.crate",
"sha256": "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb",
"dest": "cargo/vendor/bumpalo-3.20.2"
"url": "https://static.crates.io/crates/bumpalo/bumpalo-3.20.3.crate",
"sha256": "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649",
"dest": "cargo/vendor/bumpalo-3.20.3"
},
{
"type": "inline",
"contents": "{\"package\": \"5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb\", \"files\": {}}",
"dest": "cargo/vendor/bumpalo-3.20.2",
"contents": "{\"package\": \"72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649\", \"files\": {}}",
"dest": "cargo/vendor/bumpalo-3.20.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -411,14 +411,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/cc/cc-1.2.62.crate",
"sha256": "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98",
"dest": "cargo/vendor/cc-1.2.62"
"url": "https://static.crates.io/crates/cc/cc-1.2.64.crate",
"sha256": "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f",
"dest": "cargo/vendor/cc-1.2.64"
},
{
"type": "inline",
"contents": "{\"package\": \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.62",
"contents": "{\"package\": \"dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.64",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -489,14 +489,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/chrono/chrono-0.4.44.crate",
"sha256": "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0",
"dest": "cargo/vendor/chrono-0.4.44"
"url": "https://static.crates.io/crates/chrono/chrono-0.4.45.crate",
"sha256": "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327",
"dest": "cargo/vendor/chrono-0.4.45"
},
{
"type": "inline",
"contents": "{\"package\": \"c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0\", \"files\": {}}",
"dest": "cargo/vendor/chrono-0.4.44",
"contents": "{\"package\": \"1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327\", \"files\": {}}",
"dest": "cargo/vendor/chrono-0.4.45",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -944,14 +944,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/displaydoc/displaydoc-0.2.5.crate",
"sha256": "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0",
"dest": "cargo/vendor/displaydoc-0.2.5"
"url": "https://static.crates.io/crates/displaydoc/displaydoc-0.2.6.crate",
"sha256": "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f",
"dest": "cargo/vendor/displaydoc-0.2.6"
},
{
"type": "inline",
"contents": "{\"package\": \"97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0\", \"files\": {}}",
"dest": "cargo/vendor/displaydoc-0.2.5",
"contents": "{\"package\": \"1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f\", \"files\": {}}",
"dest": "cargo/vendor/displaydoc-0.2.6",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -1100,14 +1100,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/either/either-1.15.0.crate",
"sha256": "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719",
"dest": "cargo/vendor/either-1.15.0"
"url": "https://static.crates.io/crates/either/either-1.16.0.crate",
"sha256": "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e",
"dest": "cargo/vendor/either-1.16.0"
},
{
"type": "inline",
"contents": "{\"package\": \"48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719\", \"files\": {}}",
"dest": "cargo/vendor/either-1.15.0",
"contents": "{\"package\": \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\", \"files\": {}}",
"dest": "cargo/vendor/either-1.16.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -1867,14 +1867,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/http/http-1.4.0.crate",
"sha256": "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a",
"dest": "cargo/vendor/http-1.4.0"
"url": "https://static.crates.io/crates/http/http-1.4.2.crate",
"sha256": "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425",
"dest": "cargo/vendor/http-1.4.2"
},
{
"type": "inline",
"contents": "{\"package\": \"e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a\", \"files\": {}}",
"dest": "cargo/vendor/http-1.4.0",
"contents": "{\"package\": \"6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425\", \"files\": {}}",
"dest": "cargo/vendor/http-1.4.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -1919,14 +1919,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
"sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
"dest": "cargo/vendor/hyper-1.9.0"
"url": "https://static.crates.io/crates/hyper/hyper-1.10.1.crate",
"sha256": "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498",
"dest": "cargo/vendor/hyper-1.10.1"
},
{
"type": "inline",
"contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
"dest": "cargo/vendor/hyper-1.9.0",
"contents": "{\"package\": \"55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498\", \"files\": {}}",
"dest": "cargo/vendor/hyper-1.10.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2322,14 +2322,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.98.crate",
"sha256": "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08",
"dest": "cargo/vendor/js-sys-0.3.98"
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.102.crate",
"sha256": "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31",
"dest": "cargo/vendor/js-sys-0.3.102"
},
{
"type": "inline",
"contents": "{\"package\": \"67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.98",
"contents": "{\"package\": \"03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.102",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2452,14 +2452,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/libredox/libredox-0.1.16.crate",
"sha256": "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c",
"dest": "cargo/vendor/libredox-0.1.16"
"url": "https://static.crates.io/crates/libredox/libredox-0.1.17.crate",
"sha256": "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3",
"dest": "cargo/vendor/libredox-0.1.17"
},
{
"type": "inline",
"contents": "{\"package\": \"e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c\", \"files\": {}}",
"dest": "cargo/vendor/libredox-0.1.16",
"contents": "{\"package\": \"f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3\", \"files\": {}}",
"dest": "cargo/vendor/libredox-0.1.17",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2517,14 +2517,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/log/log-0.4.29.crate",
"sha256": "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897",
"dest": "cargo/vendor/log-0.4.29"
"url": "https://static.crates.io/crates/log/log-0.4.32.crate",
"sha256": "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a",
"dest": "cargo/vendor/log-0.4.32"
},
{
"type": "inline",
"contents": "{\"package\": \"5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897\", \"files\": {}}",
"dest": "cargo/vendor/log-0.4.29",
"contents": "{\"package\": \"953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a\", \"files\": {}}",
"dest": "cargo/vendor/log-0.4.32",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2556,14 +2556,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/memchr/memchr-2.8.0.crate",
"sha256": "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79",
"dest": "cargo/vendor/memchr-2.8.0"
"url": "https://static.crates.io/crates/memchr/memchr-2.8.2.crate",
"sha256": "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4",
"dest": "cargo/vendor/memchr-2.8.2"
},
{
"type": "inline",
"contents": "{\"package\": \"f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79\", \"files\": {}}",
"dest": "cargo/vendor/memchr-2.8.0",
"contents": "{\"package\": \"88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4\", \"files\": {}}",
"dest": "cargo/vendor/memchr-2.8.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2608,27 +2608,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/mio/mio-1.2.0.crate",
"sha256": "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1",
"dest": "cargo/vendor/mio-1.2.0"
"url": "https://static.crates.io/crates/mio/mio-1.2.1.crate",
"sha256": "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda",
"dest": "cargo/vendor/mio-1.2.1"
},
{
"type": "inline",
"contents": "{\"package\": \"50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1\", \"files\": {}}",
"dest": "cargo/vendor/mio-1.2.0",
"contents": "{\"package\": \"02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda\", \"files\": {}}",
"dest": "cargo/vendor/mio-1.2.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/muda/muda-0.19.1.crate",
"sha256": "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb",
"dest": "cargo/vendor/muda-0.19.1"
"url": "https://static.crates.io/crates/muda/muda-0.19.2.crate",
"sha256": "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c",
"dest": "cargo/vendor/muda-0.19.2"
},
{
"type": "inline",
"contents": "{\"package\": \"0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb\", \"files\": {}}",
"dest": "cargo/vendor/muda-0.19.1",
"contents": "{\"package\": \"47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c\", \"files\": {}}",
"dest": "cargo/vendor/muda-0.19.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2686,14 +2686,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/nix/nix-0.30.1.crate",
"sha256": "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6",
"dest": "cargo/vendor/nix-0.30.1"
"url": "https://static.crates.io/crates/nix/nix-0.31.3.crate",
"sha256": "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d",
"dest": "cargo/vendor/nix-0.31.3"
},
{
"type": "inline",
"contents": "{\"package\": \"74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6\", \"files\": {}}",
"dest": "cargo/vendor/nix-0.30.1",
"contents": "{\"package\": \"cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d\", \"files\": {}}",
"dest": "cargo/vendor/nix-0.31.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2712,14 +2712,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/num-conv/num-conv-0.2.1.crate",
"sha256": "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967",
"dest": "cargo/vendor/num-conv-0.2.1"
"url": "https://static.crates.io/crates/num-conv/num-conv-0.2.2.crate",
"sha256": "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441",
"dest": "cargo/vendor/num-conv-0.2.2"
},
{
"type": "inline",
"contents": "{\"package\": \"c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967\", \"files\": {}}",
"dest": "cargo/vendor/num-conv-0.2.1",
"contents": "{\"package\": \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\", \"files\": {}}",
"dest": "cargo/vendor/num-conv-0.2.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3024,14 +3024,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
"dest": "cargo/vendor/openssl-0.10.80"
"url": "https://static.crates.io/crates/openssl/openssl-0.10.81.crate",
"sha256": "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45",
"dest": "cargo/vendor/openssl-0.10.81"
},
{
"type": "inline",
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.80",
"contents": "{\"package\": \"77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.81",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3063,14 +3063,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
"dest": "cargo/vendor/openssl-sys-0.9.116"
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.117.crate",
"sha256": "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695",
"dest": "cargo/vendor/openssl-sys-0.9.117"
},
{
"type": "inline",
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.116",
"contents": "{\"package\": \"b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.117",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3089,14 +3089,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/os_info/os_info-3.14.0.crate",
"sha256": "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224",
"dest": "cargo/vendor/os_info-3.14.0"
"url": "https://static.crates.io/crates/os_info/os_info-3.15.0.crate",
"sha256": "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b",
"dest": "cargo/vendor/os_info-3.15.0"
},
{
"type": "inline",
"contents": "{\"package\": \"e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224\", \"files\": {}}",
"dest": "cargo/vendor/os_info-3.14.0",
"contents": "{\"package\": \"9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b\", \"files\": {}}",
"dest": "cargo/vendor/os_info-3.15.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3726,14 +3726,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/regex/regex-1.12.3.crate",
"sha256": "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276",
"dest": "cargo/vendor/regex-1.12.3"
"url": "https://static.crates.io/crates/regex/regex-1.12.4.crate",
"sha256": "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba",
"dest": "cargo/vendor/regex-1.12.4"
},
{
"type": "inline",
"contents": "{\"package\": \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\", \"files\": {}}",
"dest": "cargo/vendor/regex-1.12.3",
"contents": "{\"package\": \"f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba\", \"files\": {}}",
"dest": "cargo/vendor/regex-1.12.4",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3752,14 +3752,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.10.crate",
"sha256": "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a",
"dest": "cargo/vendor/regex-syntax-0.8.10"
"url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.11.crate",
"sha256": "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4",
"dest": "cargo/vendor/regex-syntax-0.8.11"
},
{
"type": "inline",
"contents": "{\"package\": \"dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a\", \"files\": {}}",
"dest": "cargo/vendor/regex-syntax-0.8.10",
"contents": "{\"package\": \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\", \"files\": {}}",
"dest": "cargo/vendor/regex-syntax-0.8.11",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3778,14 +3778,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.3.crate",
"sha256": "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0",
"dest": "cargo/vendor/reqwest-0.13.3"
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.4.crate",
"sha256": "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3",
"dest": "cargo/vendor/reqwest-0.13.4"
},
{
"type": "inline",
"contents": "{\"package\": \"62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0\", \"files\": {}}",
"dest": "cargo/vendor/reqwest-0.13.3",
"contents": "{\"package\": \"219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3\", \"files\": {}}",
"dest": "cargo/vendor/reqwest-0.13.4",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4129,14 +4129,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_json/serde_json-1.0.149.crate",
"sha256": "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86",
"dest": "cargo/vendor/serde_json-1.0.149"
"url": "https://static.crates.io/crates/serde_json/serde_json-1.0.150.crate",
"sha256": "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9",
"dest": "cargo/vendor/serde_json-1.0.150"
},
{
"type": "inline",
"contents": "{\"package\": \"83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86\", \"files\": {}}",
"dest": "cargo/vendor/serde_json-1.0.149",
"contents": "{\"package\": \"e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9\", \"files\": {}}",
"dest": "cargo/vendor/serde_json-1.0.150",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4194,27 +4194,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with/serde_with-3.20.0.crate",
"sha256": "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2",
"dest": "cargo/vendor/serde_with-3.20.0"
"url": "https://static.crates.io/crates/serde_with/serde_with-3.21.0.crate",
"sha256": "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c",
"dest": "cargo/vendor/serde_with-3.21.0"
},
{
"type": "inline",
"contents": "{\"package\": \"e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2\", \"files\": {}}",
"dest": "cargo/vendor/serde_with-3.20.0",
"contents": "{\"package\": \"76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c\", \"files\": {}}",
"dest": "cargo/vendor/serde_with-3.21.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.20.0.crate",
"sha256": "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac",
"dest": "cargo/vendor/serde_with_macros-3.20.0"
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.21.0.crate",
"sha256": "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660",
"dest": "cargo/vendor/serde_with_macros-3.21.0"
},
{
"type": "inline",
"contents": "{\"package\": \"b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac\", \"files\": {}}",
"dest": "cargo/vendor/serde_with_macros-3.20.0",
"contents": "{\"package\": \"84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660\", \"files\": {}}",
"dest": "cargo/vendor/serde_with_macros-3.21.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4285,14 +4285,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/shlex/shlex-1.3.0.crate",
"sha256": "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64",
"dest": "cargo/vendor/shlex-1.3.0"
"url": "https://static.crates.io/crates/shlex/shlex-2.0.1.crate",
"sha256": "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba",
"dest": "cargo/vendor/shlex-2.0.1"
},
{
"type": "inline",
"contents": "{\"package\": \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\", \"files\": {}}",
"dest": "cargo/vendor/shlex-1.3.0",
"contents": "{\"package\": \"f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba\", \"files\": {}}",
"dest": "cargo/vendor/shlex-2.0.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4376,27 +4376,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/smallvec/smallvec-1.15.1.crate",
"sha256": "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03",
"dest": "cargo/vendor/smallvec-1.15.1"
"url": "https://static.crates.io/crates/smallvec/smallvec-1.15.2.crate",
"sha256": "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90",
"dest": "cargo/vendor/smallvec-1.15.2"
},
{
"type": "inline",
"contents": "{\"package\": \"67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03\", \"files\": {}}",
"dest": "cargo/vendor/smallvec-1.15.1",
"contents": "{\"package\": \"8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90\", \"files\": {}}",
"dest": "cargo/vendor/smallvec-1.15.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/socket2/socket2-0.6.3.crate",
"sha256": "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e",
"dest": "cargo/vendor/socket2-0.6.3"
"url": "https://static.crates.io/crates/socket2/socket2-0.6.4.crate",
"sha256": "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51",
"dest": "cargo/vendor/socket2-0.6.4"
},
{
"type": "inline",
"contents": "{\"package\": \"3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e\", \"files\": {}}",
"dest": "cargo/vendor/socket2-0.6.3",
"contents": "{\"package\": \"52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51\", \"files\": {}}",
"dest": "cargo/vendor/socket2-0.6.4",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4649,14 +4649,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tao/tao-0.35.2.crate",
"sha256": "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4",
"dest": "cargo/vendor/tao-0.35.2"
"url": "https://static.crates.io/crates/tao/tao-0.35.3.crate",
"sha256": "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9",
"dest": "cargo/vendor/tao-0.35.3"
},
{
"type": "inline",
"contents": "{\"package\": \"a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4\", \"files\": {}}",
"dest": "cargo/vendor/tao-0.35.2",
"contents": "{\"package\": \"d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9\", \"files\": {}}",
"dest": "cargo/vendor/tao-0.35.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4992,40 +4992,40 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time/time-0.3.47.crate",
"sha256": "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c",
"dest": "cargo/vendor/time-0.3.47"
"url": "https://static.crates.io/crates/time/time-0.3.48.crate",
"sha256": "fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2",
"dest": "cargo/vendor/time-0.3.48"
},
{
"type": "inline",
"contents": "{\"package\": \"743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c\", \"files\": {}}",
"dest": "cargo/vendor/time-0.3.47",
"contents": "{\"package\": \"fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2\", \"files\": {}}",
"dest": "cargo/vendor/time-0.3.48",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time-core/time-core-0.1.8.crate",
"sha256": "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca",
"dest": "cargo/vendor/time-core-0.1.8"
"url": "https://static.crates.io/crates/time-core/time-core-0.1.9.crate",
"sha256": "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109",
"dest": "cargo/vendor/time-core-0.1.9"
},
{
"type": "inline",
"contents": "{\"package\": \"7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca\", \"files\": {}}",
"dest": "cargo/vendor/time-core-0.1.8",
"contents": "{\"package\": \"9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109\", \"files\": {}}",
"dest": "cargo/vendor/time-core-0.1.9",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time-macros/time-macros-0.2.27.crate",
"sha256": "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215",
"dest": "cargo/vendor/time-macros-0.2.27"
"url": "https://static.crates.io/crates/time-macros/time-macros-0.2.28.crate",
"sha256": "9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695",
"dest": "cargo/vendor/time-macros-0.2.28"
},
{
"type": "inline",
"contents": "{\"package\": \"2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215\", \"files\": {}}",
"dest": "cargo/vendor/time-macros-0.2.27",
"contents": "{\"package\": \"9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695\", \"files\": {}}",
"dest": "cargo/vendor/time-macros-0.2.28",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5239,14 +5239,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.11+spec-1.1.0.crate",
"sha256": "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b",
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0"
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.12+spec-1.1.0.crate",
"sha256": "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7",
"dest": "cargo/vendor/toml_edit-0.25.12+spec-1.1.0"
},
{
"type": "inline",
"contents": "{\"package\": \"0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0",
"contents": "{\"package\": \"d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7\", \"files\": {}}",
"dest": "cargo/vendor/toml_edit-0.25.12+spec-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5291,14 +5291,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.10.crate",
"sha256": "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51",
"dest": "cargo/vendor/tower-http-0.6.10"
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.11.crate",
"sha256": "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840",
"dest": "cargo/vendor/tower-http-0.6.11"
},
{
"type": "inline",
"contents": "{\"package\": \"68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51\", \"files\": {}}",
"dest": "cargo/vendor/tower-http-0.6.10",
"contents": "{\"package\": \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\", \"files\": {}}",
"dest": "cargo/vendor/tower-http-0.6.11",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5408,14 +5408,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/typenum/typenum-1.20.0.crate",
"sha256": "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de",
"dest": "cargo/vendor/typenum-1.20.0"
"url": "https://static.crates.io/crates/typenum/typenum-1.20.1.crate",
"sha256": "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20",
"dest": "cargo/vendor/typenum-1.20.1"
},
{
"type": "inline",
"contents": "{\"package\": \"40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de\", \"files\": {}}",
"dest": "cargo/vendor/typenum-1.20.0",
"contents": "{\"package\": \"b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20\", \"files\": {}}",
"dest": "cargo/vendor/typenum-1.20.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5499,14 +5499,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/unicode-segmentation/unicode-segmentation-1.13.2.crate",
"sha256": "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c",
"dest": "cargo/vendor/unicode-segmentation-1.13.2"
"url": "https://static.crates.io/crates/unicode-segmentation/unicode-segmentation-1.13.3.crate",
"sha256": "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8",
"dest": "cargo/vendor/unicode-segmentation-1.13.3"
},
{
"type": "inline",
"contents": "{\"package\": \"9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c\", \"files\": {}}",
"dest": "cargo/vendor/unicode-segmentation-1.13.2",
"contents": "{\"package\": \"c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8\", \"files\": {}}",
"dest": "cargo/vendor/unicode-segmentation-1.13.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5616,14 +5616,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/uuid/uuid-1.23.1.crate",
"sha256": "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76",
"dest": "cargo/vendor/uuid-1.23.1"
"url": "https://static.crates.io/crates/uuid/uuid-1.23.3.crate",
"sha256": "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7",
"dest": "cargo/vendor/uuid-1.23.3"
},
{
"type": "inline",
"contents": "{\"package\": \"ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76\", \"files\": {}}",
"dest": "cargo/vendor/uuid-1.23.1",
"contents": "{\"package\": \"144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7\", \"files\": {}}",
"dest": "cargo/vendor/uuid-1.23.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5733,14 +5733,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.3+wasi-0.2.9.crate",
"sha256": "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6",
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9"
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.4+wasi-0.2.12.crate",
"sha256": "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487",
"dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12"
},
{
"type": "inline",
"contents": "{\"package\": \"20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6\", \"files\": {}}",
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9",
"contents": "{\"package\": \"b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487\", \"files\": {}}",
"dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5759,66 +5759,66 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.121.crate",
"sha256": "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790",
"dest": "cargo/vendor/wasm-bindgen-0.2.121"
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.125.crate",
"sha256": "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a",
"dest": "cargo/vendor/wasm-bindgen-0.2.125"
},
{
"type": "inline",
"contents": "{\"package\": \"49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.121",
"contents": "{\"package\": \"8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.125",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.71.crate",
"sha256": "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71"
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.75.crate",
"sha256": "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.75"
},
{
"type": "inline",
"contents": "{\"package\": \"96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71",
"contents": "{\"package\": \"503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.75",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.121.crate",
"sha256": "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121"
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.125.crate",
"sha256": "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.125"
},
{
"type": "inline",
"contents": "{\"package\": \"8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121",
"contents": "{\"package\": \"4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.125",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.121.crate",
"sha256": "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121"
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.125.crate",
"sha256": "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.125"
},
{
"type": "inline",
"contents": "{\"package\": \"d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121",
"contents": "{\"package\": \"fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.125",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.121.crate",
"sha256": "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121"
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.125.crate",
"sha256": "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.125"
},
{
"type": "inline",
"contents": "{\"package\": \"c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121",
"contents": "{\"package\": \"23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.125",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5876,14 +5876,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.98.crate",
"sha256": "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa",
"dest": "cargo/vendor/web-sys-0.3.98"
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.102.crate",
"sha256": "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d",
"dest": "cargo/vendor/web-sys-0.3.102"
},
{
"type": "inline",
"contents": "{\"package\": \"4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.98",
"contents": "{\"package\": \"a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.102",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7137,14 +7137,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/yoke/yoke-0.8.2.crate",
"sha256": "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca",
"dest": "cargo/vendor/yoke-0.8.2"
"url": "https://static.crates.io/crates/yoke/yoke-0.8.3.crate",
"sha256": "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5",
"dest": "cargo/vendor/yoke-0.8.3"
},
{
"type": "inline",
"contents": "{\"package\": \"abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca\", \"files\": {}}",
"dest": "cargo/vendor/yoke-0.8.2",
"contents": "{\"package\": \"709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5\", \"files\": {}}",
"dest": "cargo/vendor/yoke-0.8.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7163,27 +7163,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.48.crate",
"sha256": "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9",
"dest": "cargo/vendor/zerocopy-0.8.48"
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.52.crate",
"sha256": "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f",
"dest": "cargo/vendor/zerocopy-0.8.52"
},
{
"type": "inline",
"contents": "{\"package\": \"eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-0.8.48",
"contents": "{\"package\": \"ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-0.8.52",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.48.crate",
"sha256": "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4",
"dest": "cargo/vendor/zerocopy-derive-0.8.48"
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.52.crate",
"sha256": "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930",
"dest": "cargo/vendor/zerocopy-derive-0.8.52"
},
{
"type": "inline",
"contents": "{\"package\": \"70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-derive-0.8.48",
"contents": "{\"package\": \"1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930\", \"files\": {}}",
"dest": "cargo/vendor/zerocopy-derive-0.8.52",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7215,14 +7215,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zeroize/zeroize-1.8.2.crate",
"sha256": "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0",
"dest": "cargo/vendor/zeroize-1.8.2"
"url": "https://static.crates.io/crates/zeroize/zeroize-1.9.0.crate",
"sha256": "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e",
"dest": "cargo/vendor/zeroize-1.9.0"
},
{
"type": "inline",
"contents": "{\"package\": \"b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0\", \"files\": {}}",
"dest": "cargo/vendor/zeroize-1.8.2",
"contents": "{\"package\": \"e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e\", \"files\": {}}",
"dest": "cargo/vendor/zeroize-1.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
+39 -23
View File
@@ -53,28 +53,31 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
phosphor-svelte:
specifier: ^3.1.0
version: 3.1.0(svelte@5.56.1)(vite@8.0.16)
tauri-plugin-discord-rpc-api:
specifier: github:Youwes09/tauri-plugin-discord-rpc
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/d2fd312945d0573153e0e7e2d2dfb131acecc52c
devDependencies:
'@sveltejs/adapter-node':
specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16))
version: 5.5.4(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)))
'@sveltejs/adapter-static':
specifier: ^3.0.10
version: 3.0.10(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16))
version: 3.0.10(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)))
'@sveltejs/kit':
specifier: ^2.62.0
version: 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16)
version: 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3))
'@sveltejs/vite-plugin-svelte':
specifier: ^7.1.2
version: 7.1.2(svelte@5.56.1)(vite@8.0.16)
version: 7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3))
'@tauri-apps/cli':
specifier: ^2.11.2
version: 2.11.2
'@types/node':
specifier: ^25.9.3
version: 25.9.3
phosphor-svelte:
specifier: ^3.1.0
version: 3.1.0(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3))
svelte:
specifier: ^5.56.1
version: 5.56.1
@@ -86,7 +89,7 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.0.16
version: 8.0.16(@types/node@25.9.3)
packages:
@@ -578,6 +581,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -874,6 +880,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
vite@8.0.16:
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1171,23 +1180,23 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16))':
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)))':
dependencies:
'@rollup/plugin-commonjs': 29.0.3(rollup@4.61.0)
'@rollup/plugin-json': 6.1.0(rollup@4.61.0)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.61.0)
'@sveltejs/kit': 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16)
'@sveltejs/kit': 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3))
rollup: 4.61.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)))':
dependencies:
'@sveltejs/kit': 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16)
'@sveltejs/kit': 2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3))
'@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16)':
'@sveltejs/kit@2.62.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)))(svelte@5.56.1)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.1)(vite@8.0.16)
'@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@@ -1199,18 +1208,18 @@ snapshots:
set-cookie-parser: 3.1.0
sirv: 3.0.2
svelte: 5.56.1
vite: 8.0.16
vite: 8.0.16(@types/node@25.9.3)
optionalDependencies:
typescript: 6.0.3
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16)':
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3))':
dependencies:
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.56.1
vite: 8.0.16
vitefu: 1.1.3(vite@8.0.16)
vite: 8.0.16(@types/node@25.9.3)
vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.3))
'@tauri-apps/api@2.11.0': {}
@@ -1298,6 +1307,10 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
'@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {}
@@ -1436,13 +1449,13 @@ snapshots:
path-parse@1.0.7: {}
phosphor-svelte@3.1.0(svelte@5.56.1)(vite@8.0.16):
phosphor-svelte@3.1.0(svelte@5.56.1)(vite@8.0.16(@types/node@25.9.3)):
dependencies:
estree-walker: 3.0.3
magic-string: 0.30.21
svelte: 5.56.1
optionalDependencies:
vite: 8.0.16
vite: 8.0.16(@types/node@25.9.3)
picocolors@1.1.1: {}
@@ -1579,7 +1592,9 @@ snapshots:
typescript@6.0.3: {}
vite@8.0.16:
undici-types@7.24.6: {}
vite@8.0.16(@types/node@25.9.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -1587,10 +1602,11 @@ snapshots:
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 25.9.3
fsevents: 2.3.3
vitefu@1.1.3(vite@8.0.16):
vitefu@1.1.3(vite@8.0.16(@types/node@25.9.3)):
optionalDependencies:
vite: 8.0.16
vite: 8.0.16(@types/node@25.9.3)
zimmerframe@1.1.4: {}
+84 -85
View File
@@ -117,9 +117,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.12.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
dependencies = [
"serde_core",
]
@@ -205,7 +205,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"libc",
@@ -268,9 +268,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.63"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid 1.23.2",
"uuid 1.23.3",
]
[[package]]
@@ -317,9 +317,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
@@ -398,7 +398,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
@@ -411,7 +411,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"core-foundation 0.10.1",
"libc",
]
@@ -672,7 +672,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -1228,7 +1228,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1408,9 +1408,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.1"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
@@ -1804,13 +1804,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.99"
version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1842,7 +1841,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"serde",
"unicode-segmentation",
]
@@ -1940,9 +1939,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.31"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
@@ -1963,9 +1962,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.1"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memoffset"
@@ -2005,7 +2004,7 @@ dependencies = [
[[package]]
name = "moku"
version = "0.9.4"
version = "0.10.0"
dependencies = [
"dirs 5.0.1",
"reqwest 0.12.28",
@@ -2071,7 +2070,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2101,7 +2100,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2169,7 +2168,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-core-foundation",
@@ -2182,7 +2181,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"objc2",
"objc2-foundation",
]
@@ -2203,7 +2202,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"dispatch2",
"objc2",
]
@@ -2214,7 +2213,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -2247,7 +2246,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -2274,7 +2273,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -2297,7 +2296,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
]
@@ -2308,7 +2307,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -2320,7 +2319,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -2351,7 +2350,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"objc2",
"objc2-app-kit",
@@ -2379,11 +2378,11 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.80"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
@@ -2410,9 +2409,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.116"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
@@ -2609,7 +2608,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -2880,7 +2879,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
]
[[package]]
@@ -2927,9 +2926,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
@@ -2950,9 +2949,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
@@ -3095,7 +3094,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@@ -3179,7 +3178,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid 1.23.2",
"uuid 1.23.3",
]
[[package]]
@@ -3230,7 +3229,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -3253,7 +3252,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"cssparser",
"derive_more",
"log",
@@ -3385,9 +3384,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
@@ -3405,9 +3404,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.20.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -3525,9 +3524,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
@@ -3724,7 +3723,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3758,7 +3757,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"block2",
"core-foundation 0.10.1",
"core-graphics",
@@ -3904,7 +3903,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
"uuid 1.23.2",
"uuid 1.23.3",
"walkdir",
]
@@ -4171,7 +4170,7 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid 1.23.2",
"uuid 1.23.3",
"walkdir",
]
@@ -4505,7 +4504,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"bytes",
"futures-util",
"http",
@@ -4719,9 +4718,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.2"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -4794,9 +4793,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
@@ -4812,9 +4811,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [
"cfg-if",
"once_cell",
@@ -4825,9 +4824,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.72"
version = "0.4.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4835,9 +4834,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.122"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -4845,9 +4844,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.122"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -4858,9 +4857,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.122"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [
"unicode-ident",
]
@@ -4906,7 +4905,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.12.1",
"bitflags 2.13.0",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver",
@@ -4914,9 +4913,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.99"
version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5759,7 +5758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.12.1",
"bitflags 2.13.0",
"indexmap 2.14.0",
"log",
"serde",
@@ -5885,18 +5884,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.50"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
@@ -5926,9 +5925,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moku"
version = "0.9.4"
version = "0.10.0"
edition = "2021"
[lib]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.9.4",
"version": "0.10.0",
"identifier": "io.github.MokuProject.Moku",
"build": {
"devUrl": "http://localhost:1420",
+12 -14
View File
@@ -1,12 +1,13 @@
import { detectAdapter } from '$lib/platform-adapters'
import { initPlatformService } from '$lib/platform-service'
import { initRequestManager } from '$lib/request-manager'
import { appState } from '$lib/state/app.svelte'
import { configureAuth, probeServer } from '$lib/core/auth'
import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist'
import { loadSettingsIntoState } from '$lib/state/settings.svelte'
import { historyState } from '$lib/state/history.svelte'
import { readerState } from '$lib/state/reader.svelte'
import { detectAdapter } from '$lib/platform-adapters'
import { initPlatformService } from '$lib/platform-service'
import { initRequestManager } from '$lib/request-manager'
import { appState } from '$lib/state/app.svelte'
import { configureAuth, probeServer } from '$lib/core/auth'
import { loadSettings, loadLibrary } from '$lib/core/persistence/persist'
import { loadSettingsIntoState, settingsState } from '$lib/state/settings.svelte'
import { historyState } from '$lib/state/history.svelte'
import { readerState } from '$lib/state/reader.svelte'
import { seriesState } from '$lib/state/series.svelte'
const KEY_URL = 'moku_server_url'
const KEY_AUTH = 'moku_auth_config'
@@ -34,12 +35,11 @@ async function boot() {
const [settingsData, libraryData] = await Promise.all([
loadSettings(),
loadLibrary(),
loadUpdates(),
])
await loadSettingsIntoState(settingsData.settings)
readerState.bookmarks = libraryData.bookmarks
seriesState.bookmarks = libraryData.bookmarks
readerState.markers = libraryData.markers
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
@@ -49,8 +49,6 @@ async function boot() {
appState.serverUrl = savedUrl
appState.authMode = savedAuth.mode
appState.authUser = savedAuth.user ?? ''
appState.authPass = savedAuth.pass ?? ''
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
@@ -63,7 +61,7 @@ async function boot() {
})
const isTauri = platformAdapter.platform === 'tauri'
const autoStartServer = settingsData.settings.autoStartServer ?? false
const autoStartServer = settingsState.settings.autoStartServer
if (isTauri && autoStartServer) {
appState.status = 'booting'
+25 -14
View File
@@ -6,14 +6,25 @@
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { ArrowLeftIcon, BookmarkSimpleIcon, FolderSimplePlusIcon, FolderIcon, CircleNotchIcon } from "phosphor-svelte";
import type { Manga, Source, Category } from "$lib/types";
import type { MenuEntry } from "$lib/components/shared/ui/ContextMenu.svelte";
import {
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
parseTags, tagsLabel, matchesAllTags, runConcurrent,
} from "$lib/components/browse/lib/searchFilter";
interface MenuItem {
label: string;
icon?: any;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: never;
children?: MenuEntry[];
}
interface MenuSeparator { separator: true }
type MenuEntry = MenuItem | MenuSeparator;
interface Props {
genre: string;
onBack: () => void;
@@ -63,17 +74,17 @@
const t = parseTags(filter);
const pt = t[0] ?? "";
getAdapter().getMangaList({}).then((result) => {
getAdapter().getMangaList({}).then((result: { items: Manga[] }) => {
if (!ctrl.signal.aborted) libraryManga = result.items;
}).catch(() => {});
getAdapter().getSources().then(async (allSources) => {
getAdapter().getSources().then(async (allSources: Source[]) => {
if (ctrl.signal.aborted) return;
const srcs = allSources.filter((s: Source) => s.id !== "0").slice(0, MAX_SOURCES);
sources = srcs;
for (const src of srcs) nextPageMap.set(src.id, -1);
await runConcurrent(srcs, async (src) => {
await runConcurrent(srcs, async (src: Source) => {
if (ctrl.signal.aborted) return;
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
@@ -108,7 +119,7 @@
const ctrl = new AbortController();
abortCtrl = ctrl;
try {
await runConcurrent(srcs, async (src) => {
await runConcurrent(srcs, async (src: Source) => {
const page = nextPageMap.get(src.id)!;
if (ctrl.signal.aborted) return;
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
@@ -131,7 +142,7 @@
if (!catsLoaded) {
catsLoaded = true;
getAdapter().getCategories()
.then((cats) => { categories = cats.filter((c) => c.id !== 0); })
.then((cats: Category[]) => { categories = cats.filter((c: Category) => c.id !== 0); })
.catch(console.error);
}
}
@@ -140,7 +151,7 @@
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
icon: BookmarkSimpleIcon,
disabled: m.inLibrary,
onClick: () => getAdapter().addToLibrary(String(m.id))
.then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
@@ -149,15 +160,15 @@
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x: { id: number }) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
label: (cat.mangas ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: FolderIcon,
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
icon: FolderSimplePlusIcon,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
@@ -177,7 +188,7 @@
<div class="root">
<div class="header">
<button class="back" onclick={onBack}>
<ArrowLeft size={13} weight="light" /><span>Back</span>
<ArrowLeftIcon size={13} weight="light" /><span>Back</span>
</button>
<span class="title">{label}</span>
{#if !loadingInitial || filtered.length > 0}
@@ -213,7 +224,7 @@
{#if hasMore}
<div class="show-more-cell">
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
{#if loadingMore}<CircleNotchIcon size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
</button>
</div>
{/if}
@@ -239,7 +250,7 @@
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
+9 -9
View File
@@ -37,7 +37,7 @@
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
let kw_localQuery = $state(query);
let kw_localQuery = $state("");
let kw_pending = $state(false);
interface SourceResult {
@@ -77,19 +77,19 @@
}, 2000);
}
function kwGetVisibleSources(): Source[] {
const kw_visibleSources = $derived.by(() => {
let srcs = allSources;
if (kw_selectedLangs.size > 0)
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
if (settingsState.settings.contentLevel !== "unrestricted")
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
return srcs;
}
});
async function kwDoSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) return;
const visible = kwGetVisibleSources();
const visible = kw_visibleSources;
if (!visible.length) return;
kw_abortCtrl?.abort();
@@ -102,13 +102,13 @@
await Promise.allSettled(visible.map(async (src) => {
const idx = idxOf.get(src.id)!;
try {
const result = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
const result: { items: Manga[]; hasNextPage: boolean } = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
if (ctrl.signal.aborted) return;
const mangas = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
kw_results = kw_results.map((r, i) => i === idx ? { ...r, mangas, loading: false } : r);
kw_results[idx] = { ...kw_results[idx], mangas, loading: false };
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
kw_results = kw_results.map((r, i) => i === idx ? { ...r, loading: false, error: e.message ?? "Error" } : r);
kw_results[idx] = { ...kw_results[idx], loading: false, error: e.message ?? "Error" };
}
}));
}
@@ -120,7 +120,7 @@
kw_selectedLangs = next;
}
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_visibleCount = $derived(kw_visibleSources.length);
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
@@ -315,7 +315,7 @@
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
+26 -14
View File
@@ -5,7 +5,6 @@
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { dedupeMangaById } from "$lib/core/util";
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
import type { Manga, Source } from "$lib/types";
@@ -39,6 +38,8 @@
goto(u.toString(), { replaceState: true, noScroll: true });
}
let pendingPrefill = $state("");
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
@@ -64,22 +65,31 @@
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
loadingSources = true;
getAdapter().getSources()
.then((nodes) => {
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
allSources = nodes.filter((s: Source) => s.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { loadingSources = false; });
let sourcesAbort: AbortController | null = null;
$effect(() => {
sourcesAbort?.abort();
const ctrl = new AbortController();
sourcesAbort = ctrl;
loadingSources = true;
getAdapter().getSources()
.then((nodes: Source[]) => {
if (ctrl.signal.aborted) return;
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
allSources = nodes.filter((s: Source) => s.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { if (!ctrl.signal.aborted) loadingSources = false; });
return () => { ctrl.abort(); };
});
let popular_raw: Manga[] = $state([]);
let popular_loading = $state(false);
let popular_abortCtrl: AbortController | null = null;
let popular_sourcePool: Source[] = $state([]);
let popular_sourceCursor = $state(0);
let popular_sourcePool: Source[] = [];
let popular_sourceCursor = 0;
let popular_seenIds = new Set<number>();
let popular_seenTitles = new Set<string>();
@@ -208,6 +218,7 @@
}
onDestroy(() => {
sourcesAbort?.abort();
popular_abortCtrl?.abort();
sourceCacheAbort?.abort();
});
@@ -248,11 +259,13 @@
{availableLangs}
{hasMultipleLangs}
{loadingSources}
{pendingPrefill}
popularResults={popular_results}
popularLoading={popular_loading}
{sourceCache}
query={urlQuery}
onQueryChange={setQuery}
onPrefillConsumed={() => { pendingPrefill = ""; }}
onPreview={(m) => setPreviewManga(m)}
/>
{:else if urlTab === "tag"}
@@ -263,7 +276,6 @@
{sourceCacheLoading}
{sourceCacheEnriching}
onPreview={(m) => setPreviewManga(m)}
onGenreDrill={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
/>
{:else}
<SourceTab
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
@@ -20,7 +20,7 @@
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
let src_selectedLang = $state(preferredLang || "all");
let src_selectedLang = $state(settingsState.settings.preferredExtensionLang || "all");
let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false);
@@ -122,7 +122,7 @@
function togglePinnedSource(id: string) {
const current = settingsState.settings.pinnedSourceIds ?? [];
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
settingsState.updateSettings({ pinnedSourceIds: next });
updateSettings({ pinnedSourceIds: next });
}
onDestroy(() => { src_abortCtrl?.abort(); });
@@ -356,7 +356,7 @@
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
+31 -14
View File
@@ -41,7 +41,8 @@
let tag_loadingMoreLocal = $state(false);
let tag_localOffset = $state(0);
let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null;
let tag_abortLocal: AbortController | null = null;
let tag_abortLoadMore: AbortController | null = null;
const renderLimit = $derived(settingsState.settings.renderLimit ?? 48);
@@ -52,9 +53,6 @@
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
});
$effect(() => {
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0 && activeStatuses.length === 0) {
@@ -62,6 +60,7 @@
return;
}
tag_abortLocal?.abort();
tag_abortLoadMore?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
@@ -77,6 +76,7 @@
tag_totalCount = d.totalCount;
tag_localHasNext = d.hasNextPage;
tag_localOffset = limit;
if (d.hasNextPage && tag_localResults.length < 20) tagLoadMoreLocal();
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
@@ -86,10 +86,10 @@
async function tagLoadMoreLocal() {
if (tag_loadingMoreLocal || !tag_localHasNext) return;
tag_loadingMoreLocal = true;
tag_abortLocal?.abort();
tag_abortLoadMore?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
tag_abortLoadMore = ctrl;
tag_loadingMoreLocal = true;
const limit = renderLimit;
try {
const d = await getAdapter().getMangasByGenre(
@@ -191,13 +191,22 @@
let tag_autoSearchFired = $state(false);
$effect(() => {
tag_activeTags;
tag_activeStatuses;
void tag_activeTags;
void tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
});
$effect(() => {
const _loadingLocal = tag_loadingLocal;
const _hasFilters = tag_hasActiveFilters;
const _resultLen = tag_localResults.length;
const _cacheReady = sourceCacheReady;
if (!_loadingLocal && _hasFilters && _cacheReady) {
untrack(() => {
if (!tag_autoSearchFired && !tag_searchSources && _resultLen < 20) {
tag_autoSearchFired = true;
tag_searchSources = true;
}
});
}
});
@@ -230,6 +239,7 @@
onDestroy(() => {
tag_abortLocal?.abort();
tag_abortLoadMore?.abort();
tag_fanOutAbort?.abort();
});
</script>
@@ -370,6 +380,10 @@
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
{:else if tag_localHasNext}
<div class="loadMoreRow">
<button class="loadMoreBtn" onclick={tagLoadMoreLocal}>Load more</button>
</div>
{/if}
</div>
{:else}
@@ -426,9 +440,12 @@
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.loadMoreRow { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
.loadMoreBtn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 20px; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.loadMoreBtn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
+39 -35
View File
@@ -43,20 +43,20 @@
const isDev = import.meta.env.DEV
interface Props {
mode?: 'loading' | 'idle' | 'locked'
ringFull?: boolean
failed?: boolean
notConfigured?: boolean
showCards?: boolean
showFps?: boolean
mode?: 'loading' | 'idle' | 'locked'
ringFull?: boolean
failed?: boolean
notConfigured?: boolean
showCards?: boolean
showFps?: boolean
showDevOverlay?: boolean
pinLen?: number
pinCorrect?: string
onReady?: () => void
onUnlock?: () => void
onRetry?: () => void
onBypass?: () => void
onDismiss?: () => void
pinLen?: number
pinCorrect?: string
onReady?: () => void
onUnlock?: () => void
onRetry?: () => void
onBypass?: () => void
onDismiss?: () => void
}
let {
@@ -97,7 +97,7 @@
setTimeout(() => cb?.(), EXIT_MS)
}
let animFrame: number
let animFrame = 0
let animStart: number | null = null
let animPhase = 1
@@ -117,19 +117,18 @@
}
$effect(() => {
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
if (!isTauri) return
animStart = null
animPhase = 1
animFrame = requestAnimationFrame(animateRing)
return () => cancelAnimationFrame(animFrame)
}
if (mode !== 'loading' || failed || notConfigured || ringFull || !isTauri) return
animStart = null
animPhase = 1
animFrame = requestAnimationFrame(animateRing)
return () => cancelAnimationFrame(animFrame)
})
$effect(() => {
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
cancelAnimationFrame(animFrame)
ringProg = 1
animFrame = 0
ringProg = 1
setTimeout(() => triggerExit(onReady), 650)
})
@@ -179,6 +178,7 @@
window.removeEventListener('touchstart', handler)
}
}
return () => clearInterval(iv)
})
@@ -212,7 +212,7 @@
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin)
const travel = vh + h + BUF
cards.push({
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha,
@@ -307,11 +307,12 @@
ctx.drawImage(vignette, 0, 0, cw, ch)
}
let fps = 0, fpsFrames = 0, fpsLast = 0
let fpsFrames = 0, fpsLast = -1
function tickFps(now: number) {
if (fpsLast < 0) { fpsLast = now; return }
fpsFrames++
if (now - fpsLast >= 500) {
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
const fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
fpsFrames = 0
fpsLast = now
if (fpsEl) fpsEl.textContent = `${fps} fps`
@@ -370,7 +371,7 @@
function cleanup() {
if (live) {
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
live.vignette.width = 0
live.vignette.width = 0
live.vignette.height = 0
live = null
}
@@ -444,14 +445,17 @@
document.addEventListener('visibilitychange', onVis)
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
cleanup()
extraCleanup?.()
document.removeEventListener('visibilitychange', onVis)
if (isDev && mode === 'idle') {
splashDevUnregister(el)
devLiveCount = splashDevLiveCount()
return {
destroy() {
cancelAnimationFrame(raf)
cleanup()
extraCleanup?.()
document.removeEventListener('visibilitychange', onVis)
if (isDev && mode === 'idle') {
splashDevUnregister(el)
devLiveCount = splashDevLiveCount()
}
}
}
}
@@ -548,7 +552,7 @@
.logo-wrap { position:relative; width:72px; height:72px; display:flex; align-items:center; justify-content:center; }
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
.pin-card--leaving { animation:cardOut 0.28s cubic-bezier(0.4,0,1,1) both; }
.pin-label { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wider); text-transform:uppercase; margin:0; }
+34 -9
View File
@@ -16,15 +16,22 @@
let closeDialogOpen = $state(false)
let closeRemember = $state(false)
onMount(async () => {
isFullscreen = await win.isFullscreen()
const unlistenResize = await win.onResized(async () => {
onMount(() => {
let unlistenResize: (() => void) | undefined
let unlistenClose: (() => void) | undefined
win.isFullscreen().then(v => { isFullscreen = v })
win.onResized(async () => {
isFullscreen = await win.isFullscreen()
})
const unlistenClose = await win.listen('tauri://close-requested', handleCloseRequested)
}).then(u => { unlistenResize = u })
win.listen('tauri://close-requested', handleCloseRequested)
.then(u => { unlistenClose = u })
return () => {
unlistenResize()
unlistenClose()
unlistenResize?.()
unlistenClose?.()
}
})
@@ -56,6 +63,10 @@
if (choice === 'tray') await doHide()
else await doQuit()
}
function onBackdropKey(e: KeyboardEvent) {
if (e.key === 'Escape') { closeDialogOpen = false; closeRemember = false }
}
</script>
{#if !isFullscreen}
@@ -99,8 +110,21 @@
{/if}
{#if closeDialogOpen}
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div
class="close-backdrop"
role="presentation"
onclick={() => { closeDialogOpen = false; closeRemember = false }}
onkeydown={onBackdropKey}
>
<div
class="close-dialog"
role="dialog"
aria-modal="true"
aria-label="Close Moku"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="close-header">
<p class="close-title">Close Moku?</p>
<p class="close-sub">Choose how the app should exit.</p>
@@ -169,6 +193,7 @@
0 24px 64px rgba(0,0,0,0.7),
0 8px 24px rgba(0,0,0,0.4);
animation: cdPop 0.22s cubic-bezier(0.16,1,0.3,1) both;
outline: none;
}
@keyframes cdPop { from { opacity: 0; transform: scale(0.96) translateY(6px) } to { opacity: 1; transform: none } }
-171
View File
@@ -1,171 +0,0 @@
const CARD_COUNT = 18
const CARD_W = 52
const CARD_H = 72
const CARD_RADIUS = 6
const DRIFT_SPEED = 0.018
interface Card {
x: number
y: number
vx: number
vy: number
rot: number
vrot: number
opacity: number
scale: number
hue: number
}
function makeCard(w: number, h: number): Card {
const side = Math.floor(Math.random() * 4)
const margin = 80
let x = 0, y = 0
if (side === 0) { x = Math.random() * w; y = -margin }
if (side === 1) { x = w + margin; y = Math.random() * h }
if (side === 2) { x = Math.random() * w; y = h + margin }
if (side === 3) { x = -margin; y = Math.random() * h }
const cx = w / 2, cy = h / 2
const dx = cx - x, dy = cy - y
const len = Math.sqrt(dx * dx + dy * dy) || 1
const spd = 0.12 + Math.random() * 0.1
return {
x,
y,
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
rot: Math.random() * Math.PI * 2,
vrot: (Math.random() - 0.5) * 0.006,
opacity: 0.025 + Math.random() * 0.055,
scale: 0.7 + Math.random() * 0.7,
hue: 120 + Math.random() * 40,
}
}
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
ctx.save()
ctx.globalAlpha = c.opacity
ctx.translate(c.x, c.y)
ctx.rotate(c.rot)
ctx.scale(c.scale, c.scale)
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
const x = -w / 2, y = -h / 2
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
ctx.lineWidth = 1 / c.scale
ctx.stroke()
const grad = ctx.createLinearGradient(x, y, x, y + h)
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
ctx.fillStyle = grad
ctx.fill()
ctx.restore()
}
export function mountCardCanvas(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d')!
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
let raf = 0
let running = true
function resize() {
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
ctx.scale(dpr, dpr)
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
}
function tick() {
if (!running) return
const w = canvas.offsetWidth, h = canvas.offsetHeight
ctx.clearRect(0, 0, w, h)
for (const c of cards) {
c.x += c.vx
c.y += c.vy
c.rot += c.vrot
const pad = 120
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
Object.assign(c, makeCard(w, h))
}
drawCard(ctx, c)
}
raf = requestAnimationFrame(tick)
}
const ro = new ResizeObserver(resize)
ro.observe(canvas)
resize()
tick()
return {
destroy() {
running = false
cancelAnimationFrame(raf)
ro.disconnect()
},
}
}
export function ringGeometry(r: number, pad: number) {
const size = (r + pad) * 2
const c = size / 2
const circ = 2 * Math.PI * r
return { size, c, circ }
}
const RING_STEPS = [
{ target: 0.15, duration: 400 },
{ target: 0.45, duration: 800 },
{ target: 0.72, duration: 600 },
{ target: 0.88, duration: 1000 },
{ target: 0.96, duration: 700 },
]
export function animateRingProgress(onProgress: (p: number) => void): () => void {
let current = 0.025
let stepIdx = 0
let start = performance.now()
let raf = 0
let stopped = false
function ease(t: number) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
}
function tick(now: number) {
if (stopped) return
if (stepIdx >= RING_STEPS.length) return
const step = RING_STEPS[stepIdx]
const elapsed = now - start
const t = Math.min(elapsed / step.duration, 1)
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
current = from + (step.target - from) * ease(t)
onProgress(current)
if (t >= 1) {
stepIdx++
start = now
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => { stopped = true; cancelAnimationFrame(raf) }
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import { CircleNotchIcon, ArrowClockwiseIcon, XIcon } from "phosphor-svelte";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import { longPress } from "$lib/core/ui/touchscreen";
import type { DownloadQueueItem } from "$lib/types/api";
@@ -78,12 +78,12 @@
<div class="actions">
{#if isError}
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwiseIcon size={11} weight="bold" />{/if}
</button>
{/if}
{#if !isActive}
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<XIcon size={12} weight="light" />{/if}
</button>
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import { CircleNotchIcon } from "phosphor-svelte";
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
import type { DownloadQueueItem } from "$lib/types/api";
@@ -9,16 +9,14 @@
isRunning: boolean;
dequeueing: Set<number>;
selected: Set<number>;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
onRemove, onRetry, onSelect,
}: Props = $props();
</script>
@@ -54,8 +52,6 @@
isSelected={selected.has(item.chapter.id)}
{onRemove}
{onRetry}
{onReorder}
{onReorderEdge}
{onSelect}
/>
{/each}
@@ -165,10 +165,8 @@
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
selected={downloadStore.selected}
onRemove={(id) => downloadStore.dequeue(id)}
onRetry={(id) => downloadStore.retryOne(id)}
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
onRemove={(id: number) => downloadStore.dequeue(id)}
onRetry={(id: number) => downloadStore.retryOne(id)}
onSelect={handleSelect}
/>
</div>
@@ -219,7 +217,7 @@
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; appearance: textfield; }
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
.move-input:focus { color: var(--text-primary); }
@@ -25,19 +25,16 @@
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
const isLocal = pkgName === '__local__';
const isLocal = $derived(pkgName === '__local__');
// ── Library mode state ──────────────────────────────────────────────
let groups: SourceLibrary[] = $state([]);
let sourceNodes: SourceNode[] = $state([]);
// ── Local/browse mode state ──────────────────────────────────────────
let localItems: any[] = $state([]);
let localPage: number = $state(1);
let localHasNext: boolean = $state(false);
let localLoadingMore: boolean = $state(false);
// ── Shared state ─────────────────────────────────────────────────────
let loading = $state(true);
let search = $state("");
let searchInput = $state("");
@@ -49,8 +46,6 @@
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
// ── Derived filtered lists ────────────────────────────────────────────
const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga));
const filtered = $derived((() => {
@@ -414,7 +409,7 @@
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
.card.anims .card-title { transition: color var(--t-base); }
.card-skeleton { padding: 0; }
@@ -17,7 +17,7 @@ export type SourceNode = {
id: string;
displayName: string;
isConfigurable: boolean;
extension: { pkgName: string };
extension?: { pkgName: string };
};
export function libraryByExtension(
@@ -112,12 +112,10 @@
for (let i = 0; i < entries.length; i++) {
entries[i] = { ...entries[i], status: "searching" };
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
});
const results = d.fetchSourceManga.mangas
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
.sort((a, b) => b.similarity - a.similarity);
const mangas = await getAdapter().searchManga(entries[i].manga.title, target.id);
const results = mangas
.map((m: Manga) => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
.sort((a: { manga: Manga; similarity: number }, b: { manga: Manga; similarity: number }) => b.similarity - a.similarity);
if (results.length > 0 && results[0].similarity > 0.3) {
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
@@ -147,17 +145,15 @@
for (const entry of toMigrate) {
const idx = entries.indexOf(entry);
try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
const newChaps = d.fetchChapters.chapters;
const newChaps = await getAdapter().fetchChapters(String(entry.match!.id));
const toMarkRead: number[] = [];
const toMarkBookmarked: number[] = [];
const toMarkRead: number[] = [];
for (const nc of newChaps) {
const oldIdx = entries[idx].manga;
if (oldIdx) {
toMarkRead.push(nc.id);
}
// LibraryManga has no chapter detail — use unreadCount as a proxy:
// if unreadCount < total fetched, the user had read some, so carry them all over.
const hadReads = entries[idx].manga.unreadCount < newChaps.length;
if (hadReads) {
for (const nc of newChaps) toMarkRead.push(nc.id);
}
if (toMarkRead.length)
@@ -183,7 +179,7 @@
}
</script>
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
<div class="overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }} onkeydown={(e) => { if (e.key === "Escape" && phase !== "migrating") onClose(); }}>
<div class="modal">
<div class="modal-header">
+3 -2
View File
@@ -5,6 +5,7 @@
import { getAdapter } from '$lib/request-manager'
import { libraryState } from '$lib/state/library.svelte'
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
import { openReaderForChapter } from '$lib/state/series.svelte'
import { historyState } from '$lib/state/history.svelte'
import type { ReadSession } from '$lib/types/history'
import HeroStage from '$lib/components/home/HeroStage.svelte'
@@ -107,7 +108,7 @@
heroAllChapters = all
const lastReadIdx = heroEntry
? all.findLastIndex(c => c.id === heroEntry!.endChapterId)
: all.findLastIndex(c => c.isRead)
: all.findLastIndex(c => c.read)
const startIdx = Math.max(0, lastReadIdx)
heroChapters = all.slice(startIdx, startIdx + 5)
} catch {
@@ -129,7 +130,7 @@
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
if (!heroEntry) return
const target = heroAllChapters.find(c => c.id === heroEntry!.endChapterId) ?? heroAllChapters[0]
if (target) openChapter(target)
if (target) openReaderForChapter(target, heroManga ?? null)
}
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
+140 -459
View File
@@ -1,10 +1,26 @@
<script lang="ts">
import { tick } from "svelte";
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
import type { StripChapter } from "$lib/components/reader/lib/scrollHandler";
import { READ_LINE_PCT } from "$lib/components/reader/lib/scrollHandler";
import { settingsState } from "$lib/state/settings.svelte";
import LongstripViewer from "$lib/components/reader/viewer/LongstripViewer.svelte";
import SingleViewer from "$lib/components/reader/viewer/SingleViewer.svelte";
import DoubleViewer from "$lib/components/reader/viewer/DoubleViewer.svelte";
export interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
}
type FlatPage = {
chapterId: number;
chapterName: string;
localIndex: number;
url: string;
total: number;
};
interface Props {
style: string;
@@ -15,10 +31,10 @@
pageReady: boolean;
pageGroups: number[][];
currentGroup: number[];
stripToRender: StripChapter[];
fadingOut: boolean;
tapToToggleBar: boolean;
pinchZoomEnabled: boolean;
useBlob: boolean;
barPosition: "top" | "left" | "right";
onGetZoom: () => number;
onSetZoom: (z: number) => void;
@@ -27,44 +43,58 @@
onWheel: (e: WheelEvent) => void;
onToggleUi: () => void;
bindContainer: (el: HTMLDivElement) => void;
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onCenterIdxChange:(flatIdx: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
}
const {
style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, pinchZoomEnabled, barPosition,
pageGroups, currentGroup, fadingOut,
tapToToggleBar, pinchZoomEnabled, useBlob, barPosition,
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
onPageChange, onChapterChange, onCenterIdxChange, onMarkRead, onAppend,
}: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
let stripChunks = $state<StripChapter[]>([]);
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
export function loadStrip(chapterId: number, chapterName: string, urls: string[], resumeTo = 0) {
stripChunks = [{ chapterId, chapterName, urls }];
if (resumeTo > 1) {
setTimeout(() => scrollToFlatIndex(resumeTo - 1), 0);
}
}
export async function appendStripChunk(chapterId: number, chapterName: string, urls: string[]) {
if (stripChunks.some(c => c.chapterId === chapterId)) return;
stripChunks = [...stripChunks, { chapterId, chapterName, urls }];
}
export function getStripChunks(): StripChapter[] {
return stripChunks;
}
const flatPages = $derived.by<FlatPage[]>(() => {
const out: FlatPage[] = [];
for (const chunk of stripToRender) {
for (const chunk of stripChunks) {
for (let i = 0; i < chunk.urls.length; i++) {
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
out.push({
chapterId: chunk.chapterId,
chapterName: chunk.chapterName,
localIndex: i,
url: chunk.urls[i],
total: chunk.urls.length,
});
}
}
return out;
});
let loadedSet = $state(new Set<number>());
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let currentSrc = $state<string | null>(null);
let currentGroupSrcs = $state<(string | null)[]>([]);
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
const aspectMap = new Map<number, number>();
let currentSrc = $state<string | null>(null);
let currentGroupSrcs = $state<(string | null)[]>([]);
let centerIdx = $state(0);
// ── Non-longstrip page src resolution ────────────────────────────────────
$effect(() => {
if (style === "longstrip" || !pageReady) return;
const pageNum = readerState.pageNumber;
@@ -89,218 +119,75 @@
return () => { cancelled = true; };
});
// ── Non-longstrip: scroll to top on every page change ────────────────────
// Ported from Suwayomi's useReaderScrollToStartOnPageChange.
// Prevents stale pan position carrying over when flipping pages.
$effect(() => {
void readerState.pageNumber;
if (style !== "longstrip" && containerEl) {
containerEl.scrollTo(0, 0);
}
if (style !== "longstrip" && containerEl) containerEl.scrollTo(0, 0);
});
// ── Blob URL revocation ───────────────────────────────────────────────────
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) { try { URL.revokeObjectURL(url); } catch {} }
});
}
let lastTrackedPage = 0;
let lastTrackedChapter = 0;
// ── Load window management ────────────────────────────────────────────────
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
const newSet = new Set(loadedSet);
newSet.add(idx);
loadedSet = newSet;
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (loadedSet.has(idx)) {
resolvedSrc = { ...resolvedSrc, [idx]: src };
} else {
scheduleRevoke(src);
}
});
}
function handleScroll() {
if (style !== "longstrip" || !containerEl || !flatPages.length) return;
function unloadPage(idx: number) {
if (!loadedSet.has(idx)) return;
const newSet = new Set(loadedSet);
newSet.delete(idx);
loadedSet = newSet;
const oldSrc = resolvedSrc[idx];
if (oldSrc) {
const next = { ...resolvedSrc };
delete next[idx];
resolvedSrc = next;
scheduleRevoke(oldSrc);
}
}
const containerRect = containerEl.getBoundingClientRect();
const readY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
function recalcWindow(center: number) {
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
}
}
$effect(() => { recalcWindow(centerIdx); });
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
// ── Scroll position preservation on image resize above viewport ───────────
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
//
// Problem: when a placeholder above the current scroll position loads its
// real image and changes height, the browser shifts the scroll position
// relative to the viewport (layout shift). This corrects for that by:
// 1. Tracking the first visible image and its offsetTop at last scroll.
// 2. On every ResizeObserver entry for an image above scrollTop, computing
// the delta and applying it as a scroll correction.
//
// MutationObserver watches for images being added/removed so the
// ResizeObserver stays in sync with the actual DOM without needing
// querySelectorAll on every scroll tick.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
let visibleImg: HTMLElement | undefined;
let visibleImgTop = 0;
let lastScrollTop = 0;
const onScroll = () => {
lastScrollTop = containerEl.scrollTop;
if (visibleImg) {
visibleImgTop = visibleImg.offsetTop;
}
};
containerEl.addEventListener("scroll", onScroll, { passive: true });
const intersectionObs = new IntersectionObserver((entries) => {
const first = entries.find(e => e.isIntersecting);
if (first?.target instanceof HTMLElement) {
visibleImg = first.target;
visibleImgTop = first.target.offsetTop;
}
});
const resizeObs = new ResizeObserver((entries) => {
if (!visibleImg) return;
const hasEntryBeforeScroll = entries.some(e => {
if (!(e.target instanceof HTMLElement)) return false;
// Skip zero-size preload placeholders (they are outside the load window)
if (!e.target.clientWidth && !e.target.clientHeight) return false;
return e.target.offsetTop < lastScrollTop;
});
if (!hasEntryBeforeScroll) return;
const newTop = lastScrollTop - visibleImgTop + visibleImg.offsetTop;
containerEl.scrollTo({ top: newTop, behavior: "instant" } as ScrollToOptions);
});
const observe = (el: Element) => { intersectionObs.observe(el); resizeObs.observe(el); };
const unobserve = (el: Element) => { intersectionObs.unobserve(el); resizeObs.unobserve(el); };
const mutationObs = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(observe);
});
m.removedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(unobserve);
});
}
});
mutationObs.observe(containerEl, { childList: true, subtree: true });
// Observe images already in the DOM at setup time
containerEl.querySelectorAll("img").forEach(observe);
return () => {
containerEl.removeEventListener("scroll", onScroll);
mutationObs.disconnect();
resizeObs.disconnect();
intersectionObs.disconnect();
};
});
// ── Cursor hide on inactivity (longstrip) ─────────────────────────────────
// Ported from Suwayomi's useReaderHideCursorOnInactivity.
// Hides the cursor after 5 s of mouse inactivity, restores on movement.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
const HIDE_AFTER_MS = 5_000;
let timer: ReturnType<typeof setTimeout> | null = null;
const show = () => {
containerEl.style.cursor = "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => { containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
};
show(); // start the timer immediately
window.addEventListener("mousemove", show, { passive: true });
return () => {
containerEl.style.cursor = "";
window.removeEventListener("mousemove", show);
if (timer) clearTimeout(timer);
};
});
// ── Scroll to target flat index ───────────────────────────────────────────
export function notifyScrollCenter(idx: number) {
centerIdx = idx;
}
export async function scrollToFlatIndex(idx: number) {
if (!containerEl || !flatPages.length) return;
centerIdx = idx;
recalcWindow(idx);
// Wait for Svelte to render any newly-in-window slots.
await tick();
if (!containerEl) return;
// Use scrollIntoView — the browser knows the exact element position
// regardless of image load state or aspect ratio. This is the same approach
// used by Suwayomi's useReaderHandlePageSelection (imageRef.scrollIntoView).
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
const slot = slots[idx];
if (slot) {
slot.scrollIntoView({ block: "start", behavior: "instant" });
} else {
// Slot not in DOM — proportional fallback (very unlikely after tick).
containerEl.scrollTop = (idx / flatPages.length) * containerEl.scrollHeight;
let centerFlatIdx = 0;
let bestDist = Infinity;
slots.forEach((slot, idx) => {
const rect = slot.getBoundingClientRect();
const mid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(mid - readY);
if (dist < bestDist) { bestDist = dist; centerFlatIdx = idx; }
});
onCenterIdxChange(centerFlatIdx);
const page = flatPages[centerFlatIdx];
if (!page) return;
const localPage = page.localIndex + 1;
if (localPage !== lastTrackedPage || page.chapterId !== lastTrackedChapter) {
lastTrackedPage = localPage;
lastTrackedChapter = page.chapterId;
onPageChange(localPage);
onChapterChange(page.chapterId);
}
for (const chunk of stripChunks) {
const lastLocalIdx = chunk.urls.length - 1;
let flatLastIdx = -1;
for (let i = 0; i < flatPages.length; i++) {
if (flatPages[i].chapterId === chunk.chapterId && flatPages[i].localIndex === lastLocalIdx) {
flatLastIdx = i;
break;
}
}
if (flatLastIdx < 0) continue;
const lastSlot = slots[flatLastIdx];
if (!lastSlot) continue;
const lastRect = lastSlot.getBoundingClientRect();
if (lastRect.bottom < readY) onMarkRead(chunk.chapterId);
}
const scrollBottom = containerEl.scrollTop + containerEl.clientHeight;
const scrollTotal = containerEl.scrollHeight;
if (scrollTotal - scrollBottom < containerEl.clientHeight * 1.5) onAppend();
}
// ── Reset on chapter change ───────────────────────────────────────────────
let lastChapterId = 0;
$effect(() => {
const chapterId = readerState.activeChapter?.id ?? 0;
if (chapterId === lastChapterId) return;
lastChapterId = chapterId;
loadedSet = new Set<number>();
resolvedSrc = {};
centerIdx = 0;
aspectMap.clear();
});
// ── Inspect / zoom helpers ────────────────────────────────────────────────
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
let containerEl: HTMLDivElement;
let containerEl = $state<HTMLDivElement | undefined>();
let stripRef: LongstripViewer | undefined = $state();
export function captureAnchor() { stripRef?.captureAnchor(); }
export function restoreAnchor() { stripRef?.restoreAnchor(); }
export function notifyScrollCenter(idx: number) { stripRef?.notifyScrollCenter(idx); }
export async function scrollToFlatIndex(idx: number) { await stripRef?.scrollToFlatIndex(idx); }
function getInspectImageEl(): HTMLElement | null {
if (!containerEl) return null;
@@ -325,66 +212,6 @@
let inspectPanStartX = 0;
let inspectPanStartY = 0;
let stripDragging = $state(false);
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollCurrentY = 0;
let midScrollDisplayLevel = $state(0);
let midScrollRaf: number | null = null;
function startMidScroll(originY: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const tick = () => {
if (!midScrollActive || !containerEl) return;
const dy = midScrollCurrentY - midScrollOriginY;
const deadZone = 24;
const excess = Math.max(0, Math.abs(dy) - deadZone);
const speed = Math.sign(dy) * excess * 0.12;
containerEl.scrollTop += speed;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(tick);
};
midScrollRaf = requestAnimationFrame(tick);
}
function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
function pauseAutoScroll() {
autoScrollPaused = true;
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
}
$effect(() => {
if (style !== "longstrip" || !settingsState.settings.autoScroll) return;
let rafId: number;
const tick = () => {
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
$effect(() => {
if (style !== "longstrip") stopMidScroll();
});
// ── Pinch zoom ────────────────────────────────────────────────────────────
let pinch: PinchTracker | null = null;
$effect(() => {
@@ -402,28 +229,11 @@
}
});
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
e.preventDefault();
if (midScrollActive) {
stopMidScroll();
} else {
settingsState.settings.autoScroll = false;
startMidScroll(e.clientY);
}
return;
}
if (style === "longstrip") {
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
pauseAutoScroll();
e.preventDefault();
return;
}
if (style === "longstrip") { stripRef?.onMouseDown(e); return; }
if (readerState.inspectScale <= 1) return;
inspectDragging = true;
inspectDragMoved = false;
@@ -435,13 +245,7 @@
}
export function onInspectMouseMove(e: MouseEvent) {
midScrollCurrentY = e.clientY;
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
return;
}
if (style === "longstrip") { stripRef?.onMouseMove(e); return; }
if (!inspectDragging) return;
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
@@ -452,22 +256,19 @@
}
export function onInspectMouseUp() {
stripDragging = false;
if (style === "longstrip") { stripRef?.onMouseUp(); return; }
inspectDragging = false;
}
export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
pinch?.onPointerDown(e);
if (style === "longstrip") stripRef?.onPointerDown(e);
}
export function onPointerMove(e: PointerEvent) {
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
if (style === "longstrip") { stripRef?.onPointerMove(e); return; }
if (inspectDragging) {
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
@@ -480,13 +281,16 @@
export function onPointerUp(e: PointerEvent) {
pinch?.onPointerUp(e);
if (!pinch?.isPinching()) { stripDragging = false; inspectDragging = false; }
if (!pinch?.isPinching()) {
if (style === "longstrip") stripRef?.onPointerUp();
else inspectDragging = false;
}
}
export function handleWheel(e: WheelEvent) {
if (style === "longstrip") {
if (e.ctrlKey) { onWheel(e); }
else pauseAutoScroll();
if (e.ctrlKey) onWheel(e);
else stripRef?.onWheel(e);
return;
}
if (!e.ctrlKey) { onWheel(e); return; }
@@ -496,14 +300,12 @@
if (next === readerState.inspectScale) return;
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
const img = getInspectImageEl();
const anchor = img ?? containerEl;
const anchor = img ?? containerEl ?? null;
const rect = anchor?.getBoundingClientRect();
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
const ratio = next / readerState.inspectScale;
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
const [clampedX, clampedY] = clampInspectPan(next, cx + (readerState.inspectPanX - cx) * ratio, cy + (readerState.inspectPanY - cy) * ratio);
readerState.inspectScale = next;
readerState.inspectPanX = clampedX;
readerState.inspectPanY = clampedY;
@@ -513,11 +315,10 @@
function handleTap(e: MouseEvent) {
if (style === "longstrip") {
if (stripDragMoved) { stripDragMoved = false; return; }
if (stripRef?.consumeTap()) return;
return;
}
if (inspectDragMoved) { inspectDragMoved = false; return; }
if (stripDragMoved) { stripDragMoved = false; return; }
if (tapToToggleBar) {
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
@@ -550,10 +351,10 @@
onclick={handleTap}
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
ondblclick={handleDblClick}
onscroll={style === "longstrip" ? handleScroll : undefined}
onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => {
if (e.key === " " && style === "longstrip") {
e.preventDefault();
@@ -563,28 +364,9 @@
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
}}
>
{#if midScrollActive}
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#if loading}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">
{@render skeleton()}
</div>
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
</div>
{/if}
@@ -593,76 +375,21 @@
{/if}
{#if style === "longstrip"}
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = resolvedSrc[gi]}
{@const isLoaded = loadedSet.has(gi)}
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
{#if isLoaded && src}
<img
{src}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
loading="eager"
decoding="async"
draggable="false"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
const aspect = img.naturalWidth / img.naturalHeight;
slot.style.setProperty("--aspect", String(aspect));
aspectMap.set(gi, aspect);
}
}}
/>
{:else}
<div class="strip-placeholder" aria-hidden="true">
{@render skeleton()}
</div>
{/if}
</div>
{/each}
<div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if currentSrc}
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
<LongstripViewer
bind:this={stripRef}
{containerEl}
{flatPages}
{imgCls}
{effectiveWidth}
{resolveUrl}
{barPosition}
/>
{:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i (pg)}
{#if currentGroupSrcs[i]}
<img src={currentGroupSrcs[i]} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{:else}
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
{/if}
{/each}
</div>
{:else}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
</div>
{/if}
</div>
<DoubleViewer {imgCls} {currentGroup} srcs={currentGroupSrcs} {pageGroups} />
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if currentSrc}
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
<SingleViewer {imgCls} src={currentSrc} {fadingOut} isFade={style === "fade"} />
{/if}
</div>
@@ -685,19 +412,6 @@
:global(.pinch-active) .viewer { touch-action: none; }
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.strip-slot { width: 100%; display: flex; flex-direction: column; align-items: center; }
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
@@ -728,47 +442,14 @@
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.img) { display: block; user-select: none; image-rendering: auto; }
:global(.img.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
:global(.strip-gap) { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; }
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.error-msg { color: var(--color-error); font-size: var(--text-base); }
.midscroll-bar {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
.midscroll-seg-lit { background: var(--accent-fg); }
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
</style>
+73 -142
View File
@@ -1,18 +1,18 @@
<script lang="ts">
import { onMount, untrack, tick } from "svelte";
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
import type { PageStyle } from "$lib/state/reader.svelte";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { app, appState } from "$lib/state/app.svelte";
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
import { historyState } from "$lib/state/history.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { setPreviewManga, seriesState } from "$lib/state/series.svelte";
import { getAdapter } from "$lib/request-manager";
import { setReading, clearReading } from "$lib/core/discord";
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } from "$lib/core/cache/imageCache";
@@ -46,17 +46,26 @@
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
let visibleChapterId = $state<number | null>(null);
const displayChapter = $derived(
style === "longstrip" && readerState.visibleChapterId
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
style === "longstrip" && visibleChapterId
? (readerState.activeChapterList.find(c => c.id === visibleChapterId) ?? readerState.activeChapter)
: readerState.activeChapter
);
const currentBookmark = $derived(
readerState.activeManga
? readerState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
? seriesState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
: undefined
);
const currentGroup = $derived.by(() => {
const group = style === "double" && readerState.pageGroups.length
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
: [readerState.pageNumber];
return rtl ? [...group].reverse() : group;
});
const isBookmarked = $derived(
!!currentBookmark &&
currentBookmark.chapterId === displayChapter?.id &&
@@ -71,7 +80,7 @@
const showResumeBanner = $derived(
readerState.resumeVisible && readerState.resumePage > 1 &&
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
readerState.pageNumber === readerState.resumePage
);
const adjacent = $derived.by(() => {
@@ -87,9 +96,9 @@
const visibleChunkLastPage = $derived.by(() => {
if (style !== "longstrip") return lastPage;
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
return chunk?.urls.length ?? lastPage;
const chunks = pageViewRef?.getStripChunks() ?? [];
const chId = visibleChapterId ?? readerState.activeChapter?.id;
return chunks.find(c => c.chapterId === chId)?.urls.length ?? lastPage;
});
const imgCls = $derived([
@@ -101,21 +110,6 @@
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
].filter(Boolean).join(" "));
const stripToRender = $derived(
style === "longstrip"
? (readerState.stripChapters.length > 0
? readerState.stripChapters
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
: []
);
const currentGroup = $derived.by(() => {
const group = style === "double" && readerState.pageGroups.length
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
: [readerState.pageNumber];
return rtl ? [...group].reverse() : group;
});
const sliderPage = $derived.by(() => {
if (style === "double" && readerState.pageGroups.length)
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
@@ -145,13 +139,9 @@
let abortCtrl = { current: null as AbortController | null };
let hasNavigated = false;
let startAtLastPageRef = { current: false };
let cleanupScroll: () => void = () => {};
let stripChaptersRef = readerState.stripChapters;
let tickTimer: ReturnType<typeof setTimeout> | null = null;
let progressTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { stripChaptersRef = readerState.stripChapters; });
function maybeMarkCurrentRead() {
const ch = displayChapter ?? readerState.activeChapter;
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
@@ -212,17 +202,6 @@
const startAtLast = () => { startAtLastPageRef.current = true; };
function flatIndexForPage(page: number): number {
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
const chunks = readerState.stripChapters;
let offset = 0;
for (const chunk of chunks) {
if (chunk.chapterId === chId) return offset + Math.max(0, page - 1);
offset += chunk.urls.length;
}
return Math.max(0, page - 1);
}
function primedJump(page: number, commit = true) {
if (useBlob && commit && style !== "longstrip") {
cancelQueuedFetches();
@@ -236,9 +215,8 @@
style,
lastPage,
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
stripToRender.reduce((s, c) => s + c.urls.length, 0),
readerState.visibleChapterId ?? readerState.activeChapter?.id ?? 0,
readerState.stripChapters,
visibleChapterId ?? readerState.activeChapter?.id ?? 0,
pageViewRef?.getStripChunks() ?? [],
);
}
@@ -252,9 +230,6 @@
function handleCloseReader() {
clearReading().catch(() => {});
for (const url of readerState.pageUrls) revokeBlobUrl(url);
for (const strip of readerState.stripChapters) {
for (const url of strip.urls) revokeBlobUrl(url);
}
readerState.closeReader();
}
@@ -266,7 +241,7 @@
lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] as PageStyle }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => { app.setSettingsOpen(true); },
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
@@ -281,11 +256,11 @@
},
chapterNext: () => {
const ch = rtl ? adjacent.prev : adjacent.next;
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeChapterList); }
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeManga); }
},
chapterPrev: () => {
const ch = rtl ? adjacent.next : adjacent.prev;
if (ch) readerState.openReader(ch, readerState.activeChapterList);
if (ch) readerState.openReader(ch, readerState.activeManga);
},
closePopovers: () => readerState.closeAllPopovers(),
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
@@ -295,7 +270,7 @@
function captureCurrentReaderSettings(): ReaderSettings {
return {
pageStyle: style,
pageStyle: style as PageStyle,
fitMode: fit,
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
readerZoom: zoom,
@@ -342,10 +317,11 @@
}
$effect(() => {
const ch = readerState.activeChapter;
const manga = readerState.activeManga;
if (ch && manga) {
const ch = readerState.activeChapter;
if (ch) {
untrack(() => {
const manga = readerState.activeManga;
if (!manga) return;
historyState.openSession(
manga.id, manga.title, manga.thumbnailUrl,
ch.id, ch.name, readerState.pageNumber,
@@ -367,7 +343,7 @@
$effect(() => {
const page = readerState.pageNumber;
const chId = style === "longstrip"
? (readerState.visibleChapterId ?? readerState.activeChapter?.id)
? (visibleChapterId ?? readerState.activeChapter?.id)
: readerState.activeChapter?.id;
const chName = style === "longstrip"
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
@@ -391,99 +367,33 @@
const ch = readerState.activeChapter;
const urls = readerState.pageUrls;
const resumeTo = untrack(() => readerState.resumePage);
appending = false;
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
readerState.visibleChapterId = ch.id;
tick().then(() => {
if (!containerEl) return;
if (resumeTo > 1) {
pageViewRef.scrollToFlatIndex(resumeTo - 1);
readerState.stripResumeReady = true;
return;
}
containerEl.scrollTop = 0;
});
visibleChapterId = ch.id;
appending = false;
pageViewRef.loadStrip(ch.id, ch.name, urls, resumeTo);
}
});
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
$effect(() => {
const chId = readerState.visibleChapterId;
const chId = visibleChapterId;
if (!chId || style !== "longstrip") return;
if (chId === readerState.activeChapter?.id) return;
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
if (wasAppended) {
untrack(() => {
readerState.resumePage = 0;
readerState.resumeVisible = false;
const prefs = getMangaPrefs(chId);
if (prefs.downloadAhead > 0) {
const list = readerState.activeChapterList;
const idx = list.findIndex(c => c.id === chId);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
.filter(c => !c.downloaded && !c.read)
.map(c => c.id);
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
}
}
});
return;
}
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
if (bookmark && bookmark.pageNumber > 1) {
untrack(() => {
readerState.resumePage = bookmark.pageNumber;
readerState.resumeDismissed = false;
readerState.resumeVisible = true;
readerState.stripResumeReady = true;
scheduleResumeDismiss();
});
} else {
untrack(() => readerState.resetResume());
}
});
$effect(() => {
void style;
if (!containerEl) return;
untrack(() => {
cleanupScroll();
cleanupScroll = setupScrollTracking(containerEl!, {
onPageChange: (p) => { readerState.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
if (appending || !readerState.stripChapters.length) return;
appending = true;
appendNextChapter(
stripChaptersRef,
readerState.activeChapterList,
(id) => fetchPages(id, useBlob),
(url) => preloadImage(url, useBlob),
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
() => { appending = false; },
);
},
getStripChapters: () => stripChaptersRef,
getPageUrls: () => readerState.pageUrls,
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
});
});
});
$effect(() => {
if (readerState.activeChapter && readerState.activeChapterList.length) {
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
if (idx >= 0) {
const next = readerState.activeChapterList[idx + 1];
const prev = readerState.activeChapterList[idx - 1];
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
readerState.resumePage = 0;
readerState.resumeVisible = false;
const prefs = getMangaPrefs(chId);
if (prefs.downloadAhead > 0) {
const list = readerState.activeChapterList;
const idx = list.findIndex(c => c.id === chId);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
.filter(c => !c.downloaded && !c.read)
.map(c => c.id);
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
}
}
}
});
});
$effect(() => {
@@ -552,11 +462,9 @@
if (pageNum > 1) hasNavigated = true;
untrack(() => {
if (!hasNavigated) return;
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
if (settingsState.settings.autoBookmark ?? true) {
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
if (existing) readerState.removeBookmark(existing.chapterId);
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
seriesState.setBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
}
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
@@ -606,7 +514,6 @@
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
cleanupScroll();
ro.disconnect();
};
});
@@ -677,6 +584,7 @@
resumePage={readerState.resumePage}
resumeFading={readerState.resumeFading}
{adjacent}
{barPosition}
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
/>
@@ -687,10 +595,11 @@
error={readerState.error}
pageReady={readerState.pageReady}
pageGroups={readerState.pageGroups}
{currentGroup} {stripToRender}
{currentGroup}
fadingOut={readerState.fadingOut}
{tapToToggleBar}
{pinchZoomEnabled}
{useBlob}
{barPosition}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
@@ -699,6 +608,28 @@
onWheel={handleWheel}
onToggleUi={toggleUiVisibility}
{bindContainer}
onPageChange={(p) => { readerState.pageNumber = p; }}
onChapterChange={(id) => { visibleChapterId = id; }}
onCenterIdxChange={(idx) => { pageViewRef?.notifyScrollCenter(idx); }}
onMarkRead={(id) => markChapterRead(id, markedRead)}
onAppend={() => {
if (appending) return;
const chunks = pageViewRef?.getStripChunks() ?? [];
if (!chunks.length) return;
const lastChunk = chunks[chunks.length - 1];
const list = readerState.activeChapterList;
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
const next = list[lastIdx + 1];
if (!next || chunks.some(c => c.chapterId === next.id)) return;
appending = true;
fetchPages(next.id, useBlob)
.then(urls => {
urls.slice(0, 6).forEach(url => preloadImage(url, useBlob));
return pageViewRef.appendStripChunk(next.id, next.name, urls);
})
.finally(() => { appending = false; });
}}
/>
{#snippet progressBarSnippet()}
@@ -139,7 +139,7 @@
<div class="bar-divider"></div>
<button class="icon-btn"
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev); } }}
disabled={!adjacent.prev}
title="Previous chapter">
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
@@ -179,7 +179,7 @@
</div>
<button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next); } }}
disabled={!adjacent.next}
title="Next chapter">
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
@@ -1,4 +1,5 @@
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
import { seriesState } from "$lib/state/series.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { getAdapter } from "$lib/request-manager";
import type { MangaPrefs } from "$lib/types/settings";
@@ -18,7 +19,7 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
const manga = readerState.activeManga;
if (manga && chapter) {
readerState.addBookmark({
seriesState.setBookmark({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
@@ -35,8 +36,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
const mangaId = readerState.activeManga?.id;
if (!mangaId) return;
readerState.activeChapterList = readerState.activeChapterList.map(c =>
c.id === id ? { ...c, read: true } : c
seriesState.patchChapters(mangaId, chapters =>
chapters.map(c => c.id === id ? { ...c, read: true } : c),
);
const prefs = getMangaPrefs(mangaId);
@@ -79,15 +80,13 @@ export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNu
const manga = readerState.activeManga;
if (!chapter || !manga) return;
const existing = readerState.bookmarks.find(
const existing = seriesState.bookmarks.find(
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
);
if (existing) {
readerState.removeBookmark(chapter.id);
seriesState.removeBookmark(chapter.id);
} else {
const other = readerState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
if (other) readerState.removeBookmark(other.chapterId);
readerState.addBookmark({
seriesState.setBookmark({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
@@ -103,9 +102,9 @@ export function commitMarker(color: MarkerColor, note: string, editId: string) {
const manga = readerState.activeManga;
if (!chapter || !manga) return;
if (editId) {
readerState.updateMarker(editId, { note: note.trim(), color });
seriesState.updateMarker(editId, { note: note.trim(), color });
} else {
readerState.addMarker({
seriesState.addMarker({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
+12 -6
View File
@@ -1,4 +1,5 @@
import { readerState } from "$lib/state/reader.svelte";
import { seriesState } from "$lib/state/series.svelte";
import { fetchPages } from "./pageLoader";
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
@@ -9,6 +10,7 @@ export function scheduleResumeDismiss() {
}
let prefetchedChapterId: number | null = null;
let prefetchedUrls: string[] = [];
export async function loadChapter(
id: number,
@@ -26,15 +28,12 @@ export async function loadChapter(
if (useBlob) {
clearResolvedUrlCache();
for (const url of readerState.pageUrls) revokeBlobUrl(url);
for (const strip of readerState.stripChapters) {
for (const url of strip.urls) revokeBlobUrl(url);
}
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
const prefetchedUrls = await fetchPages(prefetchedChapterId, false).catch(() => [] as string[]);
for (const url of prefetchedUrls) revokeBlobUrl(url);
clearPageCache(prefetchedChapterId);
}
prefetchedChapterId = null;
prefetchedUrls = [];
}
startAtLastPage.current = false;
@@ -42,7 +41,7 @@ export async function loadChapter(
readerState.resetForChapter();
readerState.pageUrls = [];
const bookmark = readerState.bookmarks.find(b => b.chapterId === id);
const bookmark = seriesState.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0;
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
readerState.resumeDismissed = false;
@@ -63,9 +62,16 @@ export async function loadChapter(
readerState.pageReady = true;
readerState.loading = false;
if (resumeTo > 1) readerState.resumeVisible = true;
if (adjacent.next) {
prefetchedChapterId = adjacent.next.id;
fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
fetchPages(adjacent.next.id, useBlob, ctrl.signal)
.then(fetched => {
if (!ctrl.signal.aborted && prefetchedChapterId === adjacent.next!.id) {
prefetchedUrls = fetched;
}
})
.catch(() => {});
}
} catch (e: unknown) {
if (ctrl.signal.aborted) return;
+10 -11
View File
@@ -11,11 +11,11 @@ function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: ()
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
if (forward) {
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next, readerState.activeChapterList); }
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next); }
else closeReader();
} else {
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
}
}
@@ -35,7 +35,7 @@ export function goForward(
) {
if (readerState.loading) return;
if (style === "longstrip") {
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, readerState.activeChapterList); }
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next); }
return;
}
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
@@ -46,14 +46,14 @@ export function goForward(
} else if (adjacent.next) {
onMaybeMarkRead();
readerState.pageNumber = 1;
openReader(adjacent.next, readerState.activeChapterList);
openReader(adjacent.next);
} else closeReader();
}
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
if (readerState.loading) return;
if (style === "longstrip") {
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
return;
}
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
@@ -61,7 +61,7 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
if (readerState.pageNumber > 1) {
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
else readerState.pageNumber--;
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
}
export function jumpToPage(
@@ -69,14 +69,13 @@ export function jumpToPage(
style: string,
lastPage: number,
scrollToFlatIndex: ((idx: number) => void) | null,
flatPageCount: number,
activeChapterId: number,
stripChapters: { chapterId: number; urls: string[] }[],
chunks: { chapterId: number; urls: string[] }[],
) {
if (style === "longstrip") {
if (!scrollToFlatIndex || flatPageCount === 0) return;
if (!scrollToFlatIndex || !chunks.length) return;
let offset = 0;
for (const chunk of stripChapters) {
for (const chunk of chunks) {
if (chunk.chapterId === activeChapterId) {
scrollToFlatIndex(offset + Math.max(0, page - 1));
return;
@@ -92,4 +91,4 @@ export function jumpToPage(
} else {
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache } from "$lib/core/cache/pageCache";
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache, getCachedAspect } from "$lib/core/cache/pageCache";
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
const groups: number[][] = [[1]];
@@ -10,4 +10,4 @@ export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads
else { groups.push([i, i + 1]); i += 2; }
}
return groups;
}
}
@@ -1,141 +1 @@
export const READ_LINE_PCT = 0.50;
export interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
}
export interface ScrollHandlerCallbacks {
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onCenterIdxChange: (flatIdx: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
}
/**
* Returns true if the element is considered "at" the read-line.
*
* Ported from Suwayomi's ReaderPager.utils `isPageInViewport`:
* - If the element's top is above the line AND its bottom is below it fully covers the line
* (handles a single page that is taller than the viewport).
* - If the element's top is at or below the line AND its bottom is also below it leading edge
* has crossed the line (normal scroll-past case).
*
* Using Math.trunc to avoid floating-point jitter from getBoundingClientRect.
*/
function isPageAtReadLine(el: HTMLElement, readLineY: number): boolean {
const rect = el.getBoundingClientRect();
const top = Math.trunc(rect.top);
const bottom = Math.trunc(rect.bottom);
const line = Math.trunc(readLineY);
// Element completely spans the read line (taller than viewport or very tall image)
if (top <= line && bottom >= line) return true;
// Element's top edge is at or above the line
if (top <= line) return true;
return false;
}
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const {
onPageChange, onChapterChange, onCenterIdxChange,
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks;
let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return;
const containerRect = containerEl.getBoundingClientRect();
const readLineY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
// Find the last image whose top is at or above the read line.
// Binary search is still valid here since images are ordered top-to-bottom.
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
const active = imgs[best];
const activePage = Number(active.dataset.localPage);
const activeChId = Number(active.dataset.chapter);
onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
const chunks = getStripChapters();
let flatOffset = 0;
for (const chunk of chunks) {
if (chunk.chapterId === activeChId) {
onCenterIdxChange(flatOffset + activePage - 1);
break;
}
flatOffset += chunk.urls.length;
}
if (shouldAutoMark() && activeChId) {
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId);
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
if (atBottom) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
onAppend();
}
}
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
containerEl.removeEventListener("scroll", onScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}
export function appendNextChapter(
stripChapters: StripChapter[],
chapterList: { id: number; name: string }[],
fetchPages: (chapterId: number) => Promise<string[]>,
preloadImage: (url: string) => void,
onAppended: (next: StripChapter) => void,
onDone: () => void,
): void {
if (!stripChapters.length) return;
const lastChunk = stripChapters[stripChapters.length - 1];
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
const next = chapterList[lastIdx + 1];
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
fetchPages(next.id)
.then(urls => { urls.slice(0, 6).forEach(preloadImage); return urls; })
.then(urls => {
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
onAppended({ chapterId: next.id, chapterName: next.name, urls });
onDone();
})
.catch(() => onDone());
}
@@ -0,0 +1,89 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
interface Props {
imgCls: string;
currentGroup: number[];
srcs: (string | null)[];
pageGroups: number[][];
}
const { imgCls, currentGroup, srcs, pageGroups }: Props = $props();
</script>
<div
class="inspect-wrap"
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
>
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i (pg)}
{#if srcs[i]}
<img
src={srcs[i]}
alt="Page {pg}"
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
decoding="async"
draggable="false"
/>
{:else}
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
{@render skeleton()}
</div>
{/if}
{/each}
</div>
{:else}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
</div>
{/if}
</div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; }
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
</style>
@@ -0,0 +1,409 @@
<script lang="ts">
import { tick } from "svelte";
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { getCachedAspect } from "$lib/components/reader/lib/pageLoader";
export interface StripPage {
chapterId: number;
chapterName: string;
localIndex: number;
url: string;
total: number;
}
interface Props {
containerEl: HTMLDivElement | undefined;
flatPages: StripPage[];
imgCls: string;
effectiveWidth: number | undefined;
resolveUrl: (url: string, priority?: number) => Promise<string>;
barPosition: "top" | "left" | "right";
}
const { containerEl, flatPages, imgCls, effectiveWidth, resolveUrl, barPosition }: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
let _loadedSet: Set<number> = new Set();
let _resolvedSrc: Record<number, string> = {};
let _version = $state(0);
const loadedSet = { has: (i: number) => _loadedSet.has(i) };
const resolvedSrc = { get: (i: number) => _resolvedSrc[i] as string | undefined };
let revokeQueue: string[] = [];
let centerIdx = $state(0);
const aspectMap = new Map<number, number>();
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) { try { URL.revokeObjectURL(url); } catch {} }
});
}
function loadPage(idx: number) {
if (_loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
_loadedSet.add(idx);
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (_loadedSet.has(idx)) {
_resolvedSrc[idx] = src;
_version++;
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!_loadedSet.has(idx)) return;
_loadedSet.delete(idx);
const aspect = aspectMap.get(idx);
if (aspect !== undefined && containerEl) {
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
slot?.style.setProperty("--aspect", String(aspect));
}
const oldSrc = _resolvedSrc[idx];
if (oldSrc) {
delete _resolvedSrc[idx];
scheduleRevoke(oldSrc);
}
_version++;
}
let recalcTimer: ReturnType<typeof setTimeout> | null = null;
function recalcWindow(center: number) {
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
}
}
function scheduleRecalc(center: number) {
if (recalcTimer) return;
recalcTimer = setTimeout(() => { recalcTimer = null; recalcWindow(center); }, 50);
}
$effect(() => { void _version; });
$effect(() => { recalcWindow(centerIdx); });
$effect(() => { void flatPages.length; tick().then(() => recalcWindow(centerIdx)); });
let lastChapterId = 0;
$effect(() => {
let chapterId: number;
try { chapterId = readerState.activeChapter?.id ?? 0; } catch { return; }
if (chapterId === lastChapterId) return;
lastChapterId = chapterId;
_loadedSet = new Set<number>();
_resolvedSrc = {};
centerIdx = 0;
_version++;
aspectMap.clear();
});
export function notifyScrollCenter(idx: number) {
centerIdx = idx;
scheduleRecalc(idx);
}
export async function scrollToFlatIndex(idx: number) {
if (!containerEl || !flatPages.length) return;
centerIdx = idx;
recalcWindow(idx);
await tick();
if (!containerEl) return;
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
if (slot) slot.scrollIntoView({ block: "start", behavior: "instant" });
}
let anchorEl: HTMLElement | null = null;
let anchorOffset = 0;
export function captureAnchor() {
if (!containerEl) return;
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
let best: HTMLElement | null = null;
let bestTop = -Infinity;
for (const img of imgs) {
const top = img.getBoundingClientRect().top;
if (top <= readY && top > bestTop) { bestTop = top; best = img; }
}
anchorEl = best;
anchorOffset = best ? readY - best.getBoundingClientRect().top : 0;
}
export function restoreAnchor() {
if (!containerEl || !anchorEl) return;
requestAnimationFrame(() => {
if (!anchorEl || !containerEl) return;
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
const delta = (readY - anchorEl.getBoundingClientRect().top) - anchorOffset;
containerEl.scrollTop -= delta;
anchorEl = null;
});
}
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
export function pauseAutoScroll() {
autoScrollPaused = true;
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
}
$effect(() => {
if (!settingsState.settings.autoScroll || !containerEl) return;
let rafId: number;
const tick = () => {
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
const HIDE_AFTER_MS = 5_000;
$effect(() => {
if (!containerEl) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const show = () => {
containerEl.style.cursor = "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => { if (containerEl) containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
};
show();
window.addEventListener("mousemove", show, { passive: true });
return () => {
if (containerEl) containerEl.style.cursor = "";
window.removeEventListener("mousemove", show);
if (timer) clearTimeout(timer);
};
});
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollCurrentY = 0;
let midScrollDisplayLevel = $state(0);
let midScrollRaf: number | null = null;
function startMidScroll(originY: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const frame = () => {
if (!midScrollActive || !containerEl) return;
const dy = midScrollCurrentY - midScrollOriginY;
const excess = Math.max(0, Math.abs(dy) - 24);
containerEl.scrollTop += Math.sign(dy) * excess * 0.12;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(frame);
};
midScrollRaf = requestAnimationFrame(frame);
}
export function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
let stripDragging = false;
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
function setDragCursor(dragging: boolean) {
if (containerEl) containerEl.style.cursor = dragging ? "grabbing" : "";
}
export function onMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1) {
e.preventDefault();
if (midScrollActive) stopMidScroll();
else { settingsState.settings.autoScroll = false; startMidScroll(e.clientY); }
return;
}
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
setDragCursor(true);
pauseAutoScroll();
e.preventDefault();
}
export function onMouseMove(e: MouseEvent) {
midScrollCurrentY = e.clientY;
if (!stripDragging) return;
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
export function onMouseUp() {
stripDragging = false;
setDragCursor(false);
}
export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
setDragCursor(true);
pauseAutoScroll();
}
export function onPointerMove(e: PointerEvent) {
if (!stripDragging) return;
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
export function onPointerUp() {
stripDragging = false;
setDragCursor(false);
}
export function consumeTap(): boolean {
if (stripDragMoved) { stripDragMoved = false; return true; }
return false;
}
export function onWheel(e: WheelEvent) {
if (!e.ctrlKey) pauseAutoScroll();
}
</script>
{#if midScrollActive}
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = _version >= 0 ? resolvedSrc.get(gi) : undefined}
{@const isLoaded = _version >= 0 ? loadedSet.has(gi) : false}
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId} style={getCachedAspect(page.url) != null ? `--aspect:${getCachedAspect(page.url)}` : undefined}>
{#if isLoaded && src}
<img
{src}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
loading="lazy"
decoding="async"
draggable="false"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
const aspect = img.naturalWidth / img.naturalHeight;
slot.style.setProperty("--aspect", String(aspect));
aspectMap.set(gi, aspect);
}
}}
/>
{:else}
<div class="strip-placeholder" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
{/each}
<div style="height:1px;flex-shrink:0"></div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.strip-slot { width: 100%; display: flex; flex-direction: column; align-items: center; }
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.midscroll-bar {
position: fixed; top: 50%; transform: translateY(-50%);
z-index: 200; display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base); border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
.midscroll-seg-lit { background: var(--accent-fg); }
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
</style>
@@ -0,0 +1,71 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
interface Props {
imgCls: string;
src: string | null;
fadingOut: boolean;
isFade: boolean;
}
const { imgCls, src, fadingOut, isFade }: Props = $props();
</script>
<div
class="inspect-wrap"
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
>
{#if src}
<img
{src}
alt="Page {readerState.pageNumber}"
class={imgCls}
decoding="async"
draggable="false"
style={isFade ? `opacity:${fadingOut ? 0 : 1};transition:opacity 0.1s ease` : undefined}
/>
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{/if}
</div>
{#snippet skeleton()}
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
</svg>
{/snippet}
<style>
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
stroke-dasharray: 400; stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
</style>
+51 -13
View File
@@ -4,15 +4,15 @@
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
import { homeState, clearHistory } from '$lib/state/home.svelte'
import { historyState } from '$lib/state/history.svelte'
import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte'
import { setActiveManga, openReaderForChapter, setPreviewManga } from '$lib/state/series.svelte'
import { addToast } from '$lib/state/notifications.svelte'
import { buildChapterList } from '$lib/components/series/lib/chapterList'
import { downloadStore } from '$lib/state/downloads.svelte'
import { groupByDay } from './lib/recentHistory'
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
import RecentToolbar from './RecentToolbar.svelte'
import UpdatesTab from './UpdatesTab.svelte'
import HistoryTab from './HistoryTab.svelte'
import type { Manga } from '$lib/types'
import type { Manga, Chapter } from '$lib/types'
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
import type { HistoryGroup } from './lib/recentHistory'
@@ -28,6 +28,7 @@
let updatesLoading: boolean = $state(true)
let updatesError: string | null = $state(null)
let openingId: number | null = $state(null)
let enqueueing: Set<number> = $state(new Set())
let updaterRunning: boolean = $state(false)
let lastUpdatedTs: number | null = $state(null)
let updaterFinishedJobs: number | null = $state(null)
@@ -121,9 +122,9 @@
if (force) cache.clear(key)
const [updatesRes, statusRes] = await Promise.all([
cache.get<RecentUpdate[]>(
cache.get<Chapter[]>(
key,
() => getAdapter().getRecentlyUpdated(nextCtrl.signal),
() => getAdapter().getRecentlyUpdated(),
RECENT_UPDATES_TTL_MS,
CACHE_GROUPS.LIBRARY,
),
@@ -137,7 +138,7 @@
if (nextCtrl.signal.aborted) return
updates = (updatesRes ?? [])
.filter(item => item.manga?.inLibrary)
.map(item => ({ ...item, isRead: item.read }))
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a))
} catch (e: any) {
if (nextCtrl.signal.aborted) return
@@ -168,13 +169,11 @@
const manga = mangaStub(item)
try {
const chapters = await getAdapter().getChapters(String(item.mangaId))
const sorted = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const list = buildChapterList(sorted, {})
const target = list.find(ch => ch.id === item.id)
if (target) { setActiveManga(manga); openReader(target, list) }
else setActiveManga(manga)
const target = chapters.find(ch => ch.id === item.id)
if (target) openReaderForChapter(target, manga)
else setPreviewManga(manga)
} catch {
setActiveManga(manga)
setPreviewManga(manga)
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
} finally {
openingId = null
@@ -194,6 +193,42 @@
clearHistory()
historyConfirmClear = false
}
async function enqueueUpdate(item: RecentUpdate) {
if (enqueueing.has(item.id)) return
enqueueing = new Set(enqueueing).add(item.id)
try {
const allowed = await downloadStore.enqueue(item.id)
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: item.name ?? 'Chapter' })
} catch {
addToast({ kind: 'error', title: 'Download failed', body: 'Could not queue chapter.' })
} finally {
enqueueing.delete(item.id)
enqueueing = new Set(enqueueing)
}
}
async function deleteDownloaded(item: RecentUpdate) {
try {
await getAdapter().deleteDownloadedChapters([String(item.id)])
updates = updates.map(u => u.id === item.id ? { ...u, isDownloaded: false } : u)
} catch {
addToast({ kind: 'error', title: 'Delete failed', body: 'Could not delete download.' })
}
}
async function toggleLibraryUpdate() {
try {
if (updaterRunning) {
await getAdapter().stopLibraryUpdate()
} else {
await getAdapter().startLibraryUpdate()
scheduleStatusPoll()
}
} catch (e: any) {
addToast({ kind: 'error', title: 'Update error', body: e?.message ?? 'Failed' })
}
}
</script>
<div class="root anim-fade-in">
@@ -218,13 +253,16 @@
error={updatesError}
groups={updateGroups}
{updatesSearch}
totalCount={updates.length}
totalCount={updates.filter(u => !u.isRead).length}
{openingId}
{enqueueing}
{updaterRunning}
{lastUpdatedLabel}
{updaterProgressLabel}
onOpenUpdate={openUpdate}
onOpenSeries={(item) => setActiveManga(mangaStub(item))}
onEnqueue={enqueueUpdate}
onDeleteDownload={deleteDownloaded}
/>
{:else}
<HistoryTab
+7 -16
View File
@@ -19,8 +19,10 @@
}
let {
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory, updatesLoading,
onTabChange, onHistorySearchChange, onUpdatesSearchChange, onHistoryClear, onRefreshUpdates,
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
updatesLoading,
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
onHistoryClear, onRefreshUpdates,
}: Props = $props()
</script>
@@ -57,7 +59,7 @@
class="icon-btn"
onclick={onRefreshUpdates}
disabled={updatesLoading}
title="Refresh updates"
title="Reload update list"
>
{#if updatesLoading}
<CircleNotch size={14} weight="light" class="anim-spin" />
@@ -79,19 +81,6 @@
{/if}
</div>
<button
class="icon-btn"
onclick={onRefreshUpdates}
disabled={updatesLoading}
title="Refresh library"
>
{#if updatesLoading}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowsClockwise size={14} weight="bold" />
{/if}
</button>
{#if hasHistory}
<button
class="clear-btn"
@@ -155,6 +144,8 @@
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn:disabled { opacity: 0.45; cursor: default; }
.icon-btn.running { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); background: var(--color-error-bg); }
.icon-btn.running:hover { color: var(--color-error); border-color: var(--color-error); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 8px; color: var(--text-faint); pointer-events: none; }
+32 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { BookOpen, CircleNotch } from 'phosphor-svelte'
import { BookOpen, CircleNotch, Download, Trash } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
@@ -10,17 +10,20 @@
updatesSearch: string
totalCount: number
openingId: number | null
enqueueing: Set<number>
updaterRunning: boolean
lastUpdatedLabel: string | null
updaterProgressLabel: string | null
onOpenUpdate: (item: RecentUpdate) => void
onOpenSeries: (item: RecentUpdate) => void
onEnqueue: (item: RecentUpdate) => void
onDeleteDownload: (item: RecentUpdate) => void
}
let {
loading, error, groups, updatesSearch, totalCount, openingId,
loading, error, groups, updatesSearch, totalCount, openingId, enqueueing,
updaterRunning, lastUpdatedLabel, updaterProgressLabel,
onOpenUpdate, onOpenSeries,
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
}: Props = $props()
const filteredGroups = $derived(updatesSearch.trim()
@@ -63,7 +66,7 @@
<div class="bar-sep"></div>
{/if}
{#if !loading && totalCount > 0}
<span class="status-count">{totalCount} chapter{totalCount === 1 ? '' : 's'}</span>
<span class="status-count">{totalCount} unread</span>
{/if}
</div>
</div>
@@ -138,7 +141,7 @@
<div class="update-info">
<div class="title-row">
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
{#if !item.isRead}<span class="pill">Unread</span>{/if}
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
</div>
<span class="chapter-title">{chapterLabel(item)}</span>
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
@@ -146,6 +149,17 @@
{/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}
@@ -258,12 +272,20 @@
.chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); }
.meta-row { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.pill {
padding: 2px 6px; border-radius: var(--radius-full);
background: var(--accent-muted); color: var(--accent-fg);
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase; flex-shrink: 0;
width: 6px; height: 6px; border-radius: 50%;
background: var(--color-success, #22c55e); flex-shrink: 0;
}
.row-end { color: var(--text-faint); display: flex; align-items: center; justify-content: center; width: 24px; flex-shrink: 0; }
.row-end { color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-1); justify-content: center; flex-shrink: 0; }
.dl-btn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
border: none; background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-btn-delete { color: var(--color-error); }
.dl-btn-delete:hover { background: var(--color-error-bg); }
.empty {
flex: 1; display: flex; flex-direction: column; align-items: center;
+15 -4
View File
@@ -25,7 +25,10 @@ export interface UpdateStatus {
}
export function fetchedAtMs(item: Pick<RecentUpdate, 'fetchedAt'>): number {
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now()
if (!item.fetchedAt) return Date.now()
const numeric = Number(item.fetchedAt)
if (Number.isFinite(numeric)) return numeric * 1000
const ts = new Date(item.fetchedAt).getTime()
return Number.isFinite(ts) ? ts : Date.now()
}
@@ -42,10 +45,18 @@ export function parseServerTimestamp(value: unknown): number | null {
export function groupUpdatesByDay(updates: RecentUpdate[]): UpdateGroup[] {
const grouped: Record<string, RecentUpdate[]> = {}
const order: Record<string, number> = {}
for (const item of updates) {
const label = dayLabel(fetchedAtMs(item))
if (!grouped[label]) grouped[label] = []
const ts = fetchedAtMs(item)
const label = dayLabel(ts)
if (!grouped[label]) {
grouped[label] = []
order[label] = ts
}
grouped[label].push(item)
if (ts > order[label]) order[label] = ts
}
return Object.entries(grouped).map(([label, items]) => ({ label, items }))
return Object.entries(grouped)
.sort(([a], [b]) => order[b] - order[a])
.map(([label, items]) => ({ label, items }))
}
+45 -32
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Download, CheckCircle, Circle, CircleNotch, Trash } from 'phosphor-svelte'
import { Download, CheckSquare, Square, CircleNotch, Trash } from 'phosphor-svelte'
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
import { longPress } from '$lib/core/ui/touchscreen'
@@ -14,27 +14,39 @@
enqueueing: Set<number>
chapterPage: number
totalPages: number
scrollEl?: HTMLDivElement | null
onOpen: (ch: Chapter, inProgress: boolean) => void
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void
onEnqueue: (ch: Chapter, e: MouseEvent) => void
onDeleteDownload:(id: number) => void
onPageChange: (page: number) => void
onPageSizeChange:(n: number) => void
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[]
}
let {
pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems,
onPageChange, onPageSizeChange, buildCtxItems,
}: Props = $props()
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null)
let listEl: HTMLDivElement | null = $state(null)
const hasSelection = $derived(selectedIds.size > 0)
$effect(() => {
if (!listEl || viewMode !== 'list') return
const ro = new ResizeObserver(([entry]) => {
const firstRow = listEl!.querySelector('.ch-row') as HTMLElement | null
const rowH = firstRow ? firstRow.offsetHeight : 37
const n = Math.max(1, Math.floor(entry.contentRect.height / rowH))
onPageSizeChange(n)
})
ro.observe(listEl)
return () => ro.disconnect()
})
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
const [ch, idx] = param
return longPress(node, {
@@ -50,7 +62,7 @@
}
</script>
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={scrollEl}>
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={listEl}>
{#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === 'grid'}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
@@ -65,15 +77,13 @@
{:else if viewMode === 'grid'}
{#each sortedChapters as ch, i}
{@const inProgress = !ch.read && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)}
<button
class="grid-cell"
class:read={ch.read}
class:in-progress={inProgress}
class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, !ch.read && (ch.lastPageRead ?? 0) > 0)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
title={ch.name}
>
@@ -102,7 +112,7 @@
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted } }}
>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
{#if isSelected}<CheckSquare size={15} weight="fill" />{:else}<Square size={15} weight="light" />{/if}
</button>
<div class="ch-left">
<span class="ch-name">{ch.name}</span>
@@ -113,7 +123,7 @@
</div>
</div>
<div class="ch-right">
{#if ch.read}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.read}<CheckSquare size={14} weight="light" class="read-icon" />{/if}
{#if ch.downloaded}
<div class="ch-dl-wrap">
<Download size={13} weight="fill" class="ch-dl-icon" />
@@ -147,48 +157,51 @@
{/if}
<style>
.ch-list { flex: 1; overflow-y: auto; }
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-list { flex: 1; overflow: hidden; }
.ch-grid { flex: 1; overflow: hidden; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
.ch-row { display: flex; align-items: center; padding: 8px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
.ch-row:hover { background: var(--bg-raised); }
.ch-row.read { opacity: 0.45; }
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.ch-row.read { opacity: 0.5; }
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); }
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
:global(.read-icon) { color: var(--text-faint); }
:global(.enqueue-icon) { color: var(--text-faint); }
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
.ch-row:hover .dl-btn { opacity: 1; }
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete { color: var(--color-error) !important; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
.ch-dl-wrap { display: flex; align-items: center; gap: var(--sp-1); }
:global(.ch-dl-icon) { color: var(--text-faint); }
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
.ch-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; }
.ch-check {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; flex-shrink: 0;
border-radius: var(--radius-sm); border: none; background: none;
color: var(--text-faint); cursor: pointer; padding: 0;
opacity: 0;
transform: translateX(-6px);
transition: opacity var(--t-fast), transform var(--t-fast), color var(--t-fast);
margin-right: -20px;
}
.ch-row:hover .ch-check { opacity: 1; transform: translateX(0); margin-right: 0; }
.ch-check-visible { opacity: 1 !important; transform: translateX(0) !important; margin-right: 0 !important; }
.ch-selected .ch-check { color: var(--accent-fg); }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
.grid-cell-num { font-size: 10px; }
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--text-faint); }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--accent-fg); }
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
+11 -28
View File
@@ -6,7 +6,6 @@
} from 'phosphor-svelte'
import type { Chapter, Category } from '$lib/types'
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
import { updateSettings } from '$lib/state/settings.svelte'
interface ContinueChapter {
chapter: Chapter
@@ -20,8 +19,6 @@
sortMode: ChapterSortMode
sortDir: ChapterSortDir
viewMode: 'list' | 'grid'
chapterPage: number
totalPages: number
downloadedCount: number
totalCount: number
deletingAll: boolean
@@ -52,11 +49,13 @@
onSetScanlatorBlacklist: (v: string[]) => void
onSetScanlatorForce: (v: boolean) => void
onOpenFolder: () => void
onSortModeChange: (v: ChapterSortMode) => void
onSortDirChange: (v: ChapterSortDir) => void
}
let {
chapters, sortedChapters, sortMode, sortDir, viewMode,
chapterPage, totalPages, downloadedCount, totalCount, deletingAll,
downloadedCount, totalCount, deletingAll,
hasSelection, selectedCount, continueChapter,
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
allCategories, mangaCategories, catsLoading, refreshing,
@@ -64,7 +63,7 @@
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
onOpenFolder,
onOpenFolder, onSortModeChange, onSortDirChange,
}: Props = $props()
let sortMenuOpen: boolean = $state(false)
@@ -166,11 +165,11 @@
<button
class="sort-option"
class:active={sortMode === val}
onclick={() => { updateSettings({ chapterSortMode: val as ChapterSortMode }); onPageChange(1); sortMenuOpen = false }}
onclick={() => { onSortModeChange(val as ChapterSortMode); onPageChange(1); sortMenuOpen = false }}
>{label}</button>
{/each}
<div class="sort-divider"></div>
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === 'desc' ? 'asc' : 'desc' }); onPageChange(1); sortMenuOpen = false }}>
<button class="sort-option" onclick={() => { onSortDirChange(sortDir === 'desc' ? 'asc' : 'desc'); onPageChange(1); sortMenuOpen = false }}>
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
</button>
</div>
@@ -276,9 +275,11 @@
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
</button>
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
<FolderOpen size={14} weight="light" />
</button>
{#if downloadedCount > 0}
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
<FolderOpen size={14} weight="light" />
</button>
{/if}
<div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
@@ -376,13 +377,6 @@
</div>
{/if}
{#if totalPages > 1}
<div class="pagination">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}></button>
</div>
{/if}
</div>
</div>
@@ -571,17 +565,6 @@
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
.page-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
+160 -241
View File
@@ -8,21 +8,23 @@
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
} from 'phosphor-svelte'
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
type MenuSeparator = { separator: true }
type MenuItem = { label: string; icon?: any; onClick: () => void; danger?: boolean; disabled?: boolean; separator?: never; children?: MenuEntry[] }
type MenuEntry = MenuItem | MenuSeparator
import { getManga, getMangaList } from '$lib/request-manager/manga'
import { getChapters, fetchChapters, markChapterRead, markChaptersRead, deleteDownloadedChapters } from '$lib/request-manager/chapters'
import { markChapterRead, markChaptersRead, deleteDownloadedChapters, fetchChapters } from '$lib/request-manager/chapters'
import { downloadStore } from '$lib/state/downloads.svelte'
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
import { saveScroll, getScroll } from '$lib/state/app.svelte'
import { seriesState, openReader, addBookmark,
acknowledgeUpdate, clearMarkersForManga } from '$lib/state/series.svelte'
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
import { seriesState, openReaderForChapter, acknowledgeUpdate, addBookmark, clearMarkersForManga } from '$lib/state/series.svelte'
import { updateSettings } from '$lib/state/settings.svelte'
import { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte'
import type { MangaPrefs } from '$lib/types/settings'
import { addToast } from '$lib/state/notifications.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
import { buildChapterList } from '$lib/components/series/lib/chapterList'
import { getPref, setPref } from '$lib/components/series/lib/mangaPrefs'
import { getPref, setPref } from '$lib/state/series.svelte'
import { openMangaFolder } from '$lib/core/filesystem'
import type { Manga, Chapter, Category } from '$lib/types'
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
@@ -35,17 +37,13 @@
interface Props { mangaId: number }
let { mangaId }: Props = $props()
const CHAPTERS_PER_PAGE = 25
const MANGA_TTL_MS = 5 * 60 * 1000
const CHAPTER_TTL_MS = 2 * 60 * 1000
let chaptersPerPage: number = $state(25)
const MANGA_TTL_MS = 5 * 60 * 1000
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
const chapterCache: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map()
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
let manga: Manga | null = $state(null)
let chapters: Chapter[] = $state([])
let loadingManga: boolean = $state(false)
let loadingChapters: boolean = $state(true)
let enqueueing: Set<number> = $state(new Set())
let togglingLibrary: boolean = $state(false)
let chapterPage: number = $state(1)
@@ -66,42 +64,28 @@
let catsLoading: boolean = $state(false)
let chapterListEl: HTMLDivElement | null = $state(null)
let mangaAbort: AbortController | null = null
let chapterAbort: AbortController | null = null
let loadingFor: number | null = null
let prevChapterIds = new Set<number>()
let prevMangaId: number | null = null
let mangaAbort: AbortController | null = null
let prevMangaId: number | null = null
const get = <K extends keyof MangaPrefs>(key: K) =>
mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key]
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
if (mangaId) setPref(mangaId, key, value)
}
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key)
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value)
const hasSelection = $derived(selectedIds.size > 0)
const sortDir = $derived(seriesState.settings.chapterSortDir)
const sortMode = $derived(seriesState.settings.chapterSortMode ?? 'source')
const scanlatorFilter = $derived((get('scanlatorFilter') ?? []) as string[])
const scanlatorBlacklist = $derived((get('scanlatorBlacklist') ?? []) as string[])
const scanlatorForce = $derived((get('scanlatorForce') ?? false) as boolean)
const currentPrefs = $derived({
sortMode,
sortDir,
preferredScanlator: get('preferredScanlator') as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
})
const chapters = $derived(seriesState.chaptersFor(mangaId))
const loadingChapters = $derived(seriesState.isLoadingChapters(mangaId))
const sortedChapters = $derived(seriesState.activeChapterList)
const hasSelection = $derived(selectedIds.size > 0)
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
)
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs))
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE))
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE))
const scanlatorFilter = $derived(get('scanlatorFilter') as string[])
const scanlatorBlacklist = $derived(get('scanlatorBlacklist') as string[])
const scanlatorForce = $derived(get('scanlatorForce') as boolean)
const totalPages = $derived(Math.ceil(sortedChapters.length / chaptersPerPage))
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * chaptersPerPage, chapterPage * chaptersPerPage))
const readCount = $derived(sortedChapters.filter(c => c.read).length)
const totalCount = $derived(sortedChapters.length)
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0)
@@ -111,17 +95,14 @@
if (!sortedChapters.length) return null
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const anyRead = asc.some(c => c.read)
const bookmark = mangaId
? seriesState.bookmarks.find(b => b.mangaId === mangaId)
: null
const bookmark = seriesState.bookmarks.find(b => b.mangaId === mangaId)
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
if (bookmarkedCh && !bookmarkedCh.read) {
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber }
}
if (bookmarkedCh && !bookmarkedCh.read)
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: bookmark!.pageNumber }
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
const firstUnread = asc.find(c => !c.read)
const target = inProgress ?? firstUnread
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as const, resumePage: null }
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: null }
return { chapter: asc[0], type: 'reread' as const, resumePage: null }
})())
@@ -146,17 +127,6 @@
selectedIds = next
}
function applyChapters(nodes: Chapter[]) {
if (get('autoDownload') && prevChapterIds.size > 0) {
const filtered = buildChapterList(nodes, currentPrefs)
const newChapters = filtered.filter(c => !prevChapterIds.has(c.id) && !c.downloaded)
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id))
}
prevChapterIds = new Set(nodes.map(c => c.id))
chapters = nodes
if (mangaId && nodes.length > 0) checkAndMarkCompleted(mangaId, nodes)
}
function loadCategories(id: number) {
catsLoading = true
getCategories()
@@ -169,96 +139,63 @@
}
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
if (chaps.length && manga?.status !== 'ONGOING') {
const allRead = chaps.every(c => c.read)
const completed = allCategories.find(c => c.name === 'Completed')
if (completed) {
const inCompleted = mangaCategories.some(c => c.id === completed.id)
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed]
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
}
if (!chaps.length || manga?.status === 'ONGOING') return
const allRead = chaps.every(c => c.read)
const completed = allCategories.find(c => c.name === 'Completed')
if (!completed) return
const inCompleted = mangaCategories.some(c => c.id === completed.id)
if (allRead && !inCompleted) {
await updateMangaCategories(String(id), [completed.id], []).catch(console.error)
mangaCategories = [...mangaCategories, completed]
} else if (!allRead && inCompleted) {
await updateMangaCategories(String(id), [], [completed.id]).catch(console.error)
mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
}
}
function loadMangaData(id: number) {
mangaAbort?.abort()
const ctrl = new AbortController()
mangaAbort = ctrl; loadingFor = id
mangaAbort = ctrl
const cached = mangaCache.get(id)
if (cached) {
manga = cached.data; loadingManga = false
manga = cached.data
loadingManga = false
seriesState.setActiveManga(cached.data)
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
getManga(id, ctrl.signal).then(m => {
if (ctrl.signal.aborted || loadingFor !== id) return
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
manga = m
}).catch(() => {})
getManga(id, ctrl.signal)
.then(m => {
if (ctrl.signal.aborted) return
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
manga = m
seriesState.setActiveManga(m)
})
.catch(() => {})
return
}
loadingManga = true
getManga(id, ctrl.signal).then(m => {
if (ctrl.signal.aborted || loadingFor !== id) return
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
manga = m
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false })
}
function loadChaptersData(id: number) {
chapterAbort?.abort()
const ctrl = new AbortController()
chapterAbort = ctrl
const cached = chapterCache.get(id)
if (cached) {
applyChapters(cached.data); loadingChapters = false
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return
fetchChapters(id, ctrl.signal)
.then(() => getChapters(id, ctrl.signal))
.then(nodes => {
if (ctrl.signal.aborted || loadingFor !== id) return
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
applyChapters(nodes)
}).catch(() => {})
return
}
chapters = []; loadingChapters = true
getChapters(id, ctrl.signal).then(nodes => {
if (ctrl.signal.aborted || loadingFor !== id) return
applyChapters(nodes); loadingChapters = false
return fetchChapters(id, ctrl.signal)
.then(() => getChapters(id, ctrl.signal))
.then(fresh => {
if (ctrl.signal.aborted || loadingFor !== id) return
chapterCache.set(id, { data: fresh, fetchedAt: Date.now() })
applyChapters(fresh)
})
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false })
}
async function syncTrackersIntoChapters(id: number, chaps: Chapter[]) {
if (!seriesState.settings.trackerSyncBack) return
const records = trackingState.recordsFor(id)
if (!records.length) return
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(id, record, chaps, currentPrefs)
if (markedIds.length > 0) {
const idSet = new Set(markedIds)
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: true } : c)
chapterCache.set(id, { data: chapters, fetchedAt: Date.now() })
}
} catch {}
}
getManga(id, ctrl.signal)
.then(m => {
if (ctrl.signal.aborted) return
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
manga = m
seriesState.setActiveManga(m)
})
.catch(() => {})
.finally(() => { if (!ctrl.signal.aborted) loadingManga = false })
}
$effect(() => {
const id = mangaId
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
if (id) untrack(() => {
untrack(() => {
acknowledgeUpdate(id)
loadMangaData(id)
loadChaptersData(id)
seriesState.loadChapters(id).then(() => {
checkAndMarkCompleted(id, seriesState.chaptersFor(id))
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id))
})
loadCategories(id)
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id, chapters))
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(manga, allMangaForLink)
@@ -266,10 +203,7 @@
} else {
loadingLinkList = true
getMangaList()
.then(list => {
allMangaForLink = list
return autoLinkLibrary(manga, list)
})
.then(list => { allMangaForLink = list; return autoLinkLibrary(manga, list) })
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
.catch(console.error)
.finally(() => { loadingLinkList = false })
@@ -278,13 +212,9 @@
})
})
let prevChapterId: number | null = null
$effect(() => {
const wasOpen = prevChapterId !== null
prevChapterId = seriesState.activeChapter?.id ?? null
if (wasOpen && !seriesState.activeChapter) {
untrack(() => { reloadChapters(mangaId) })
}
const wasOpen = seriesState.activeChapter !== null
if (!wasOpen) untrack(() => seriesState.loadChapters(mangaId, { force: true }))
})
$effect(() => {
@@ -292,12 +222,33 @@
if (id === prevMangaId) return
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
prevMangaId = id
if (chapterListEl && id !== null) {
chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
}
if (chapterListEl) chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
})
$effect(() => () => { mangaAbort?.abort(); chapterAbort?.abort() })
$effect(() => () => { mangaAbort?.abort() })
async function syncTrackersIntoChapters(id: number) {
if (!seriesState.settings.trackerSyncBack) return
const records = trackingState.recordsFor(id)
if (!records.length) return
const prefs = {
sortMode: seriesState.settings.chapterSortMode,
sortDir: seriesState.settings.chapterSortDir,
preferredScanlator: get('preferredScanlator') as string,
scanlatorFilter: scanlatorFilter,
scanlatorBlacklist: scanlatorBlacklist,
scanlatorForce: scanlatorForce,
}
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(id, record, seriesState.chaptersFor(id), prefs)
if (markedIds.length > 0) {
const idSet = new Set(markedIds)
seriesState.patchChapters(id, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: true } : c))
}
} catch {}
}
}
async function toggleLibrary() {
if (!manga) return
@@ -305,23 +256,18 @@
const next = !manga.inLibrary
await updateManga(manga.id, { inLibrary: next }).catch(console.error)
manga = { ...manga, inLibrary: next }
if (mangaCache.has(manga.id)) { const e = mangaCache.get(manga.id)!; mangaCache.set(manga.id, { ...e, data: manga }) }
seriesState.setActiveManga(manga)
if (mangaCache.has(manga.id)) mangaCache.set(manga.id, { data: manga, fetchedAt: mangaCache.get(manga.id)!.fetchedAt })
togglingLibrary = false
}
async function reloadChapters(id: number) {
const nodes = await getChapters(id)
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
applyChapters(nodes)
}
async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation()
enqueueing = new Set(enqueueing).add(ch.id)
const allowed = await downloadStore.enqueue(ch.id)
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
reloadChapters(mangaId)
seriesState.loadChapters(mangaId, { force: true })
}
async function enqueueMultiple(chapterIds: number[]) {
@@ -331,26 +277,28 @@
if (!allowed) return
}
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
reloadChapters(mangaId)
seriesState.loadChapters(mangaId, { force: true })
}
async function markRead(chapterId: number, isRead: boolean) {
await markChapterRead(chapterId, isRead).catch(console.error)
chapters = chapters.map(c => c.id === chapterId ? { ...c, read: isRead } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
checkAndMarkCompleted(mangaId, chapters)
const ch = chapters.find(c => c.id === chapterId)
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, read: isRead } : c))
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
const ch = seriesState.chaptersFor(mangaId).find(c => c.id === chapterId)
const currentPrefs = {
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
preferredScanlator: get('preferredScanlator') as string,
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
}
if (ch) {
if (isRead) await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs)
else await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
if (isRead) await trackingState.updateFromRead(mangaId, ch, seriesState.chaptersFor(mangaId), currentPrefs)
else await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
}
if (isRead) {
if (get('deleteOnRead')) {
if (ch?.downloaded) {
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
if (delayMs === 0) deleteDownloaded(chapterId)
else setTimeout(() => deleteDownloaded(chapterId), delayMs)
}
if (get('deleteOnRead') && ch?.downloaded) {
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
const doDelete = () => deleteDownloaded(chapterId)
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
}
const ahead = get('downloadAhead') as number
if (ahead > 0) {
@@ -367,24 +315,27 @@
if (!ids.length) return
await markChaptersRead(ids, isRead).catch(console.error)
const idSet = new Set(ids)
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
checkAndMarkCompleted(mangaId, chapters)
seriesState.patchChapters(mangaId, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c))
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
const currentPrefs = {
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
preferredScanlator: get('preferredScanlator') as string,
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
}
if (isRead) {
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1)
if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs)
const chaps = seriesState.chaptersFor(mangaId)
const lastRead = [...chaps].sort((a, b) => a.sourceOrder - b.sourceOrder).filter(c => idSet.has(c.id)).at(-1)
if (lastRead) await trackingState.updateFromRead(mangaId, lastRead, chaps, currentPrefs)
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
}
if (isRead && get('deleteOnRead')) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.downloaded)
const toDelete = ids.filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
if (toDelete.length) {
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
const doDelete = async () => {
await deleteDownloadedChapters(toDelete).catch(console.error)
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
seriesState.patchChapters(mangaId, chaps => chaps.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c))
}
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
}
@@ -392,17 +343,16 @@
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.downloaded)
const ids = [...selectedIds].filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
if (ids.length) {
await deleteDownloadedChapters(ids).catch(console.error)
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c))
}
clearSelection()
}
async function downloadSelected() {
await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.downloaded))
await enqueueMultiple([...selectedIds].filter(id => !seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded))
clearSelection()
}
@@ -418,29 +368,30 @@
async function deleteDownloaded(chapterId: number) {
await deleteDownloadedChapters([chapterId]).catch(console.error)
chapters = chapters.map(c => c.id === chapterId ? { ...c, downloaded: false } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, downloaded: false } : c))
}
async function deleteAllDownloads() {
const ids = chapters.filter(c => c.downloaded).map(c => c.id)
const ids = seriesState.chaptersFor(mangaId).filter(c => c.downloaded).map(c => c.id)
if (!ids.length) return
deletingAll = true
await deleteDownloadedChapters(ids).catch(console.error)
chapters = chapters.map(c => ({ ...c, downloaded: false }))
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ({ ...c, downloaded: false })))
deletingAll = false
}
async function refreshChapters() {
if (refreshing) return
refreshing = true
chapterCache.delete(mangaId)
seriesState.invalidateChapters(mangaId)
fetchChapters(mangaId)
.then(() => reloadChapters(mangaId))
.then(() => addToast({ kind: 'success', title: 'Chapters refreshed', body: `${chapters.length} chapter${chapters.length !== 1 ? 's' : ''} available` }))
.then(() => seriesState.loadChapters(mangaId, { force: true }))
.then(() => {
const count = seriesState.chaptersFor(mangaId).length
addToast({ kind: 'success', title: 'Chapters refreshed', body: `${count} chapter${count !== 1 ? 's' : ''} available` })
})
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
.finally(() => refreshing = false)
.finally(() => { refreshing = false })
}
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
@@ -472,57 +423,25 @@
}
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const resumePage = inProgress ? ch.lastPageRead ?? null : null
const ahead = get('downloadAhead') as number
if (ahead > 0) {
const idx = ascList.indexOf(ch)
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
if (toQueue.length) enqueueMultiple(toQueue)
}
}
if (inProgress && resumePage && resumePage > 1) {
if (inProgress && ch.lastPageRead && ch.lastPageRead > 1) {
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
if (!existing || existing.pageNumber < resumePage) {
if (!existing || existing.pageNumber < ch.lastPageRead) {
addBookmark({
mangaId,
mangaTitle: manga!.title,
thumbnailUrl: manga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
pageNumber: ch.lastPageRead,
})
}
}
openReader(ch, ascList, manga)
openReaderForChapter(ch, manga)
}
function handleContinue(cc: typeof continueChapter) {
if (!cc) return
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const ahead = get('downloadAhead') as number
if (ahead > 0) {
const idx = ascList.indexOf(cc.chapter)
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
if (toQueue.length) enqueueMultiple(toQueue)
}
}
if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) {
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
if (!existing || existing.pageNumber < cc.resumePage) {
addBookmark({
mangaId,
mangaTitle: manga!.title,
thumbnailUrl: manga!.thumbnailUrl,
chapterId: cc.chapter.id,
chapterName: cc.chapter.name,
pageNumber: cc.resumePage,
})
}
}
openReader(cc.chapter, ascList, manga)
interface ContinueChapter { chapter: Chapter; type: 'start' | 'continue' | 'reread'; resumePage: number | null }
function handleContinue(cc: ContinueChapter) {
openReaderForChapter(cc.chapter, manga)
}
async function openLinkPicker() {
@@ -548,10 +467,10 @@
async function toggleCategory(cat: Category) {
const inCat = mangaCategories.some(c => c.id === cat.id)
try {
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
await updateMangaCategories(String(mangaId), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
if (!inCat && !manga?.inLibrary) {
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
if (manga) manga = { ...manga, inLibrary: true }
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
}
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
} catch (e) { console.error(e) }
@@ -561,10 +480,10 @@
if (!name) return
try {
const cat = await createCategoryReq(name)
await updateMangaCategories(mangaId, [cat.id], [])
await updateMangaCategories(String(mangaId), [cat.id], [])
if (!manga?.inLibrary) {
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
if (manga) manga = { ...manga, inLibrary: true }
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
}
allCategories = [...allCategories, cat]
mangaCategories = [...mangaCategories, cat]
@@ -590,7 +509,7 @@
{loadingLinkList}
{mangaCategories}
{togglingLibrary}
onRead={handleContinue}
onRead={(ch) => handleContinue(ch)}
onToggleLibrary={toggleLibrary}
onDeleteAll={deleteAllDownloads}
onMigrateOpen={() => migrateOpen = true}
@@ -602,15 +521,13 @@
onGenreClick={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
/>
<div class="list-wrap">
<div class="list-wrap" bind:this={chapterListEl}>
<SeriesActions
{chapters}
{sortedChapters}
{sortMode}
{sortDir}
sortMode={seriesState.settings.chapterSortMode}
sortDir={seriesState.settings.chapterSortDir}
{viewMode}
{chapterPage}
{totalPages}
{downloadedCount}
{totalCount}
{deletingAll}
@@ -640,6 +557,8 @@
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
onSortModeChange={(v) => updateSettings({ chapterSortMode: v })}
onSortDirChange={(v) => updateSettings({ chapterSortDir: v })}
onOpenFolder={() => manga && openMangaFolder(manga)}
/>
@@ -652,12 +571,12 @@
{enqueueing}
{chapterPage}
{totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect}
onEnqueue={enqueue}
onDeleteDownload={deleteDownloaded}
onPageChange={(p) => chapterPage = p}
onPageSizeChange={(n) => { chaptersPerPage = n; chapterPage = Math.min(chapterPage, Math.ceil(sortedChapters.length / n) || 1) }}
{buildCtxItems}
/>
</div>
@@ -666,7 +585,7 @@
{#if markersOpen && manga}
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
<MarkersPanel mangaId={manga.id} {chapters} onClose={() => markersOpen = false} />
<MarkersPanel mangaId={manga.id} chapters={seriesState.chaptersFor(manga.id)} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
@@ -702,7 +621,7 @@
{#if migrateOpen && manga}
<MigrateModal
{manga}
currentChapters={chapters}
currentChapters={seriesState.chaptersFor(manga.id)}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
/>
@@ -163,7 +163,7 @@
<Play size={12} weight="fill" />
{continueChapter.type === 'reread' ? 'Read again'
: continueChapter.type === 'start' ? 'Start reading'
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ''}`}
: `Continue · Ch.${continueChapter.chapter.chapterNumber}`}
</button>
{/if}
<div class="actions">
@@ -64,16 +64,4 @@ export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
}
export function buildReaderChapterList(
chapters: Chapter[],
prefs: Pick<ChapterDisplayPrefs, 'preferredScanlator' | 'scanlatorFilter'> | undefined,
): Chapter[] {
return buildChapterList(chapters, {
sortMode: 'source',
sortDir: 'asc',
preferredScanlator: prefs?.preferredScanlator,
scanlatorFilter: prefs?.scanlatorFilter,
})
}
@@ -1,19 +0,0 @@
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
import type { MangaPrefs } from '$lib/types/settings'
export { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]
}
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...settingsState.settings.mangaPrefs,
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
})
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { getPref, setPref } from "$lib/components/series/lib/mangaPrefs";
import { getPref, setPref } from "$lib/state/series.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { libraryState } from "$lib/state/library.svelte";
import { resolvedCover } from "$lib/core/cover/coverResolver";
@@ -1,6 +1,6 @@
<script lang="ts">
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
import { setPref } from "$lib/components/series/lib/mangaPrefs";
import { setPref } from "$lib/state/series.svelte";
import { coverCandidatesSync, dedupeByImage } from "$lib/core/cover/coverResolver";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import type { Manga } from "$lib/types";
@@ -1,6 +1,6 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { seriesState, updateMarker, removeMarker, openReader } from "$lib/state/series.svelte";
import { seriesState, updateMarker, removeMarker, openReaderForChapter } from "$lib/state/series.svelte";
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
import type { Chapter } from "$lib/types";
@@ -58,7 +58,7 @@
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
openReaderForChapter(chapter);
}
function formatDate(ts: number): string {
+7 -2
View File
@@ -129,9 +129,10 @@ export function buildIssueUrl(
): string {
const base = 'https://github.com/moku-project/Moku/issues/new'
const prefix = type === 'bug' ? '[Bug] ' : '[Feature Request] '
const common = {
template: type === 'bug' ? 'bug_report.yml' : 'feature_request.yml',
title,
title: title.startsWith(prefix) ? title : `${prefix}${title}`,
environment: buildEnvironmentBlock(serverVersion),
}
@@ -149,6 +150,10 @@ export function buildIssueUrl(
alternatives: (fields as FeatureFields).alternatives,
}
const params = new URLSearchParams({ ...common, ...specific })
const merged: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...common, ...specific })) {
if (v !== undefined) merged[k] = v
}
const params = new URLSearchParams(merged)
return `${base}?${params.toString()}`
}
@@ -54,7 +54,7 @@
const name = newFolderName.trim()
if (!name) return
try {
const cat = await getAdapter().createCategory({ name })
const cat = await getAdapter().createCategory(name)
categories = [...categories, cat]
newFolderName = ''
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
@@ -65,7 +65,7 @@
async function commitEdit() {
if (editingId !== null && editingName.trim()) {
try {
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
await (getAdapter() as any).updateCategory(editingId, { name: editingName.trim() })
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
}
@@ -74,7 +74,7 @@
async function deleteFolder(id: number) {
try {
await getAdapter().deleteCategory({ id })
await getAdapter().deleteCategory(id)
categories = categories.filter(c => c.id !== id)
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
}
@@ -85,7 +85,7 @@
const next = !cat[flag]
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
try {
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
await (getAdapter() as any).updateCategories([id], { [flag]: next ? 'INCLUDE' : 'EXCLUDE' })
} catch (e: any) {
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
catsError = e?.message ?? 'Failed to update folder'
@@ -117,7 +117,7 @@
const optimistic = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
categories = optimistic
const serverPosition = sToIdx + 1
getAdapter().updateCategoryOrder({ id: fromNumId, position: serverPosition })
getAdapter().updateCategoryOrder(fromNumId, serverPosition)
.then((updated: Category[]) => {
categories = [
...zeroCat,
@@ -189,6 +189,7 @@
{#if isBuiltin || cat}
<div
class="s-folder-row"
role="listitem"
class:dragging={dragStrId === id}
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
@@ -205,7 +206,7 @@
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
<span class="s-folder-count">{cat?.mangas?.nodes?.length ?? 0} manga</span>
<span class="s-folder-count">{cat?.mangas?.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
@@ -235,16 +236,17 @@
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
<div class="s-folder-identity" role="button" tabindex="0" draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragend={onDragEnd}>
ondragend={onDragEnd}
onkeydown={(e) => e.key === 'Enter' && startEdit(cat.id, cat.name)}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
<button class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</button>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-count">{cat.mangas?.length ?? 0} manga</span>
<div class="s-folder-actions">
<button class="s-btn-icon"
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
@@ -332,6 +334,8 @@
.s-folder-icon {
display: grid;
flex-shrink: 0;
overflow: visible;
padding: 1px;
}
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
.s-folder-icon > :global(*:last-child) { opacity: 0; }
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, untrack } from "svelte";
import {
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
@@ -16,17 +16,13 @@
import { addToast } from "$lib/state/notifications.svelte";
import {
seriesState,
setPreviewManga, setActiveManga, openReader, addBookmark,
setPreviewManga, addBookmark, openReaderForChapter,
} from "$lib/state/series.svelte";
import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte';
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
@@ -44,8 +40,6 @@
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = app.navPage;
const linkedIds = $derived(
seriesState.previewManga
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
@@ -57,12 +51,15 @@
);
const displayManga = $derived(manga ?? seriesState.previewManga);
const mangaId = $derived(seriesState.previewManga?.id ?? null);
const chapters = $derived(mangaId != null ? seriesState.chaptersFor(mangaId) : []);
const loadingChapters = $derived(mangaId != null ? seriesState.isLoadingChapters(mangaId) : false);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.read).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.downloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.bookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? seriesState.previewManga?.inLibrary ?? false);
const inLibrary = $derived((manga as Manga | null)?.inLibrary ?? seriesState.previewManga?.inLibrary ?? false);
const scanlators = $derived(
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
);
@@ -101,7 +98,7 @@
const inProgress = asc.find((c) => !c.read && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find((c) => !c.read);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" as const : "start" as const), resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
@@ -113,16 +110,12 @@
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let detailAbort: AbortController | null = null;
function close() {
detailAbort?.abort();
chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
manga = null; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
@@ -134,10 +127,14 @@
linkPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
getAdapter().getMangaList({})
.then((d) => { allMangaForLink = d.items; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
try {
const result = await getAdapter().getMangaList({});
allMangaForLink = result.items;
} catch (e) {
console.error(e);
} finally {
loadingLinkList = false;
}
}
function closeLinkPicker() { linkPickerOpen = false; }
@@ -146,91 +143,82 @@
coverPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
getAdapter().getMangaList({})
.then((d) => { allMangaForLink = d.items; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
try {
const result = await getAdapter().getMangaList({});
allMangaForLink = result.items;
} catch (e) {
console.error(e);
} finally {
loadingLinkList = false;
}
}
$effect(() => {
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
const focal = seriesState.previewManga;
if (focal) {
originNavPage = app.navPage;
load(focal.id);
if (!focal) return;
untrack(() => {
loadDetail(focal.id);
seriesState.loadChapters(focal.id);
loadCategories(focal.id);
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(focal, allMangaForLink)
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
.then((n: number) => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
getAdapter().getMangaList({})
.then((d) => {
allMangaForLink = d.items;
return autoLinkLibrary(focal, d.items);
})
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
(async () => {
try {
const result = await getAdapter().getMangaList({});
allMangaForLink = result.items;
const n = await autoLinkLibrary(focal, result.items);
if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` });
} catch (e) {
console.error(e);
} finally {
loadingLinkList = false;
}
})();
}
}
}
});
});
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
async function loadDetail(id: number) {
detailAbort?.abort();
const ctrl = new AbortController();
detailAbort = ctrl;
manga = seriesState.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
descExpanded = false; fetchError = null;
loadingDetail = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>;
try {
return await getAdapter().fetchManga(String(id), dCtrl.signal);
} catch (e: any) {
if (e?.name === "AbortError") throw e;
const local = await getAdapter().getManga(String(id), dCtrl.signal);
if (local) return local;
throw new Error("Could not load manga details");
const key = CACHE_KEYS.MANGA(id);
try {
let fullManga: Manga;
if (cache.has(key)) {
fullManga = await (cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>);
} else {
try {
fullManga = await getAdapter().fetchManga(String(id), ctrl.signal);
} catch (e: any) {
if (e?.name === "AbortError") return;
const local = await getAdapter().getManga(String(id), ctrl.signal);
if (local) fullManga = local;
else throw new Error("Could not load manga details");
}
if (!cache.has(key)) cache.get(key, () => Promise.resolve(fullManga));
}
})()
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
if (!cache.has(CACHE_KEYS.MANGA(id)))
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
manga = fullManga; loadingDetail = false;
})
.catch((e) => {
if (e?.name === "AbortError") return;
manga = seriesState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
getAdapter().getChapters(String(id), cCtrl.signal)
.then(async (nodes) => {
if (cCtrl.signal.aborted) return;
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (sorted.length === 0) {
try {
const fetched = await getAdapter().fetchChapters(String(id), cCtrl.signal);
if (!cCtrl.signal.aborted)
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}
if (!cCtrl.signal.aborted) {
chapters = sorted;
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
if (ctrl.signal.aborted) return;
manga = fullManga;
} catch (e: any) {
if (e?.name === "AbortError") return;
manga = seriesState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
} finally {
if (!ctrl.signal.aborted) loadingDetail = false;
}
}
async function toggleLibrary() {
@@ -258,58 +246,68 @@
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
app.setNavPage(originNavPage);
goto(`/series/${displayManga.id}`);
close();
}
function loadCategories(mangaId: number) {
function handleRead() {
if (!continueChapter || !displayManga) return;
const { ch, type, resumePage } = continueChapter;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga.id,
mangaTitle: displayManga.title,
thumbnailUrl: displayManga.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReaderForChapter(ch, displayManga);
close();
}
function loadCategories(id: number) {
catsLoading = true;
getAdapter().getCategories()
.then((cats) => {
allCategories = cats.filter((c) => c.id !== 0);
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === mangaId));
.then((cats: Category[]) => {
allCategories = cats.filter((c: Category) => c.id !== 0);
mangaCategories = allCategories.filter((c: Category) => c.mangas?.some((m: Manga) => m.id === id));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = (manga ?? displayManga)?.status;
const isOngoing = mangaStatus === "ONGOING";
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
const isOngoing = (manga ?? displayManga)?.status === "ONGOING";
if (!chaps.length || isOngoing) return;
const allRead = chaps.every((c) => c.read);
const completed = allCategories.find((c) => c.name === "Completed");
if (!completed) return;
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
if (allRead && !inCompleted) {
await getAdapter().updateMangaCategories(String(mangaId), [completed.id], []).catch(console.error);
await getAdapter().updateMangaCategories(String(id), [completed.id], []).catch(console.error);
mangaCategories = [...mangaCategories, completed];
} else if (!allRead && inCompleted) {
await getAdapter().updateMangaCategories(String(mangaId), [], [completed.id]).catch(console.error);
await getAdapter().updateMangaCategories(String(id), [], [completed.id]).catch(console.error);
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
}
}
async function toggleCategory(cat: Category) {
if (!seriesState.previewManga) return;
const mangaId = seriesState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await getAdapter().updateMangaCategories(
String(mangaId),
inCat ? [] : [cat.id],
inCat ? [cat.id] : [],
).catch(console.error);
const id = seriesState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await getAdapter().updateMangaCategories(String(id), inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
if (!inCat && !inLibrary) {
await getAdapter().addToLibrary(String(mangaId)).catch(console.error);
await getAdapter().addToLibrary(String(id)).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
cache.clear(CACHE_KEYS.LIBRARY);
}
mangaCategories = inCat
? mangaCategories.filter((c) => c.id !== cat.id)
: [...mangaCategories, cat];
mangaCategories = inCat ? mangaCategories.filter((c) => c.id !== cat.id) : [...mangaCategories, cat];
}
async function handleFolderCreate() {
@@ -349,7 +347,6 @@
onDestroy(() => {
window.removeEventListener("keydown", onKey);
detailAbort?.abort();
chapterAbort?.abort();
});
</script>
@@ -548,24 +545,7 @@
</div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, chapters, displayManga);
close();
}}>
<button class="read-btn" onclick={handleRead}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
@@ -676,8 +656,6 @@
/>
{/if}
<style>
.backdrop {
position: fixed; inset: 0;
@@ -873,8 +851,8 @@
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
.desc-toggle {
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
+4
View File
@@ -97,6 +97,10 @@ export function clearResolvedUrlCache(): void {
aspectCache.clear();
}
export function getCachedAspect(url: string): number | undefined {
return aspectCache.get(url);
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
+7 -6
View File
@@ -17,8 +17,9 @@ interface StoredVault {
data: string
}
function toB64(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
function toB64(buf: ArrayBuffer | Uint8Array): string {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
return btoa(String.fromCharCode(...bytes))
}
function fromB64(s: string): Uint8Array {
@@ -29,7 +30,7 @@ async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder()
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: SALT_ITERATIONS, hash: 'SHA-256' },
{ name: 'PBKDF2', salt: salt.slice(), iterations: SALT_ITERATIONS, hash: 'SHA-256' },
keyMat,
{ name: 'AES-GCM', length: 256 },
false,
@@ -74,11 +75,11 @@ export async function unlockVault(pin: string): Promise<VaultPayload | null> {
if (!stored) return null
try {
const key = await deriveKey(pin, fromB64(stored.salt))
const key = await deriveKey(pin, fromB64(stored.salt).slice())
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromB64(stored.iv) },
{ name: 'AES-GCM', iv: fromB64(stored.iv).slice() },
key,
fromB64(stored.data),
fromB64(stored.data).slice(),
)
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload
} catch {
+6 -61
View File
@@ -1,6 +1,4 @@
import { getAdapter } from "$lib/request-manager";
import { seriesState } from "$lib/state/series.svelte";
import { readerState } from "$lib/state/reader.svelte";
import { getAdapter } from "$lib/request-manager";
import type { Chapter } from "$lib/types";
export async function getChapters(mangaId: number, signal?: AbortSignal): Promise<Chapter[]> {
@@ -11,78 +9,25 @@ export async function fetchChapters(mangaId: number, signal?: AbortSignal): Prom
return getAdapter().fetchChapters(String(mangaId), signal);
}
export async function loadChapters(mangaId: string) {
seriesState.chaptersLoading = true;
seriesState.chaptersError = null;
try {
seriesState.chapters = await getAdapter().getChapters(mangaId);
} catch (e) {
seriesState.chaptersError = String(e);
} finally {
seriesState.chaptersLoading = false;
}
}
export async function loadChapterPages(chapterId: string, signal?: AbortSignal) {
readerState.pagesLoading = true;
readerState.pagesError = null;
try {
readerState.pages = await getAdapter().getChapterPages(chapterId, signal);
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") return;
readerState.pagesError = String(e);
} finally {
readerState.pagesLoading = false;
}
}
export async function markChapterRead(id: number, read: boolean) {
export async function markChapterRead(id: number, read: boolean): Promise<void> {
await getAdapter().markChapterRead(String(id), read);
const chapter = seriesState.chapters.find(c => c.id === id);
if (chapter) chapter.read = read;
}
export async function markChaptersRead(ids: number[], read: boolean) {
export async function markChaptersRead(ids: number[], read: boolean): Promise<void> {
await getAdapter().markChaptersRead(ids.map(String), read);
const idSet = new Set(ids);
for (const c of seriesState.chapters) {
if (idSet.has(c.id)) c.read = read;
}
}
export async function markRead(id: string, read: boolean) {
await getAdapter().markChapterRead(id, read);
const numId = Number(id);
const chapter = seriesState.chapters.find(c => c.id === numId);
if (chapter) chapter.read = read;
}
export async function markManyRead(ids: string[], read: boolean) {
export async function markManyRead(ids: string[], read: boolean): Promise<void> {
await getAdapter().markChaptersRead(ids, read);
const numIds = new Set(ids.map(Number));
for (const c of seriesState.chapters) {
if (numIds.has(c.id)) c.read = read;
}
}
export async function updateChaptersProgress(
ids: string[],
patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number },
) {
): Promise<void> {
await getAdapter().updateChaptersProgress(ids, patch);
const numIds = new Set(ids.map(Number));
for (const c of seriesState.chapters) {
if (!numIds.has(c.id)) continue;
if (patch.isRead !== undefined) c.read = patch.isRead;
if (patch.isBookmarked !== undefined) c.bookmarked = patch.isBookmarked;
if (patch.lastPageRead !== undefined) c.lastPageRead = patch.lastPageRead;
}
}
export async function deleteDownloadedChapters(ids: number[]) {
export async function deleteDownloadedChapters(ids: number[]): Promise<void> {
await getAdapter().deleteDownloadedChapters(ids.map(String));
const idSet = new Set(ids);
for (const c of seriesState.chapters) {
if (idSet.has(c.id)) c.downloaded = false;
}
}
+7 -23
View File
@@ -3,7 +3,7 @@ import { libraryState } from "$lib/state/library.svelte";
import { addToast } from "$lib/state/notifications.svelte";
import { seriesState } from "$lib/state/series.svelte";
import type { MangaFilters, MangaMeta } from "$lib/server-adapters/types";
import type { Manga, Chapter, Category } from "$lib/types";
import type { Manga, Category } from "$lib/types";
export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) {
libraryState.loading = true;
@@ -36,28 +36,12 @@ export async function updateManga(id: number, patch: { inLibrary?: boolean }): P
if (patch.inLibrary === false) await getAdapter().removeFromLibrary(String(id));
}
export async function loadManga(id: string) {
seriesState.loading = true;
seriesState.error = null;
try {
seriesState.current = await getAdapter().getManga(id);
} catch (e) {
seriesState.error = String(e);
} finally {
seriesState.loading = false;
}
export async function loadManga(id: string): Promise<Manga> {
return getAdapter().getManga(id);
}
export async function fetchManga(id: string) {
seriesState.loading = true;
seriesState.error = null;
try {
seriesState.current = await getAdapter().fetchManga(id);
} catch (e) {
seriesState.error = String(e);
} finally {
seriesState.loading = false;
}
export async function fetchManga(id: string): Promise<Manga> {
return getAdapter().fetchManga(id);
}
export async function searchManga(query: string, sourceId?: string) {
@@ -84,12 +68,12 @@ export async function removeFromLibrary(mangaId: string) {
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
await getAdapter().updateMangaMeta(id, meta);
if (String(seriesState.current?.id) === id) await loadManga(id);
if (String(seriesState.activeManga?.id) === id) seriesState.setActiveManga(await getAdapter().getManga(id));
}
export async function deleteMangaMeta(id: string, key: string) {
await getAdapter().deleteMangaMeta(id, key);
if (String(seriesState.current?.id) === id) await loadManga(id);
if (String(seriesState.activeManga?.id) === id) seriesState.setActiveManga(await getAdapter().getManga(id));
}
export async function refreshLibrary() {
+2 -2
View File
@@ -20,9 +20,9 @@ export const GET_CHAPTER = `
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300, filter: { inLibrary: { equalTo: true } }) {
nodes {
id name chapterNumber sourceOrder isRead lastPageRead mangaId fetchedAt
id name chapterNumber sourceOrder isRead isDownloaded lastPageRead mangaId fetchedAt
manga { id title thumbnailUrl inLibrary }
}
}
+26 -5
View File
@@ -270,9 +270,9 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
}
async getChapters(mangaId: string): Promise<Chapter[]> {
async getChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) }
GET_CHAPTERS, { mangaId: Number(mangaId) }, signal
)
return data.chapters.nodes.map(mapChapter)
}
@@ -291,9 +291,9 @@ export class SuwayomiAdapter implements ServerAdapter {
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
}
async fetchChapters(mangaId: string): Promise<Chapter[]> {
async fetchChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]> {
const data = await this.gql<{ fetchChapters: { chapters: Record<string, unknown>[] } }>(
FETCH_CHAPTERS, { mangaId: Number(mangaId) }
FETCH_CHAPTERS, { mangaId: Number(mangaId) }, signal
)
return data.fetchChapters.chapters.map(mapChapter)
}
@@ -491,6 +491,21 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DELETE_CATEGORY, { id })
}
async updateCategory(id: number, patch: { name?: string; includeInUpdate?: string; includeInDownload?: string }): Promise<Category> {
const data = await this.gql<{ updateCategory: { category: Record<string, unknown> } }>(
UPDATE_CATEGORY, { id, ...patch }
)
return mapCategory(data.updateCategory.category)
}
async updateCategories(
ids: number[],
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
): Promise<void> {
// Suwayomi has no bulk-category-patch mutation; fan out individually.
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
}
async updateCategoryOrder(id: number, position: number): Promise<Category[]> {
const data = await this.gql<{ updateCategoryOrder: { categories: Record<string, unknown>[] } }>(
UPDATE_CATEGORY_ORDER, { id, position }
@@ -676,6 +691,10 @@ export class SuwayomiAdapter implements ServerAdapter {
return []
}
async startLibraryUpdate(): Promise<void> {
await this.gql(UPDATE_LIBRARY)
}
async stopLibraryUpdate(): Promise<void> {
await this.gql(UPDATE_STOP)
}
@@ -685,9 +704,11 @@ export class SuwayomiAdapter implements ServerAdapter {
libraryUpdateStatus: {
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number }
}
lastUpdateTimestamp: { timestamp: string } | null
}>(LIBRARY_UPDATE_STATUS)
const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo
return { isRunning, finishedJobs, totalJobs }
const lastUpdated = data.lastUpdateTimestamp ? Number(data.lastUpdateTimestamp.timestamp) : undefined
return { isRunning, finishedJobs, totalJobs, lastUpdated }
}
clearPageCache(chapterId?: number): void {
+3 -3
View File
@@ -134,9 +134,9 @@ export const CREATE_CATEGORY = `
`
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category { id name order }
mutation UpdateCategory($id: Int!, $name: String, $includeInUpdate: IncludeOrExclude, $includeInDownload: IncludeOrExclude) {
updateCategory(input: { id: $id, patch: { name: $name, includeInUpdate: $includeInUpdate, includeInDownload: $includeInDownload } }) {
category { id name order includeInUpdate includeInDownload }
}
}
`
+208 -94
View File
@@ -1,126 +1,240 @@
export type PlatformFeature =
| 'server-management'
| 'biometric-auth'
| 'native-window'
| 'filesystem'
| 'app-updates'
| 'discord-rpc'
import type { DownloadStatus } from '$lib/types/api'
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
export type Platform = 'tauri' | 'capacitor' | 'web'
export interface ServerLaunchConfig {
binary?: string
binaryArgs?: string
webUiEnabled?: boolean
export interface ServerConfig {
baseUrl: string
credentials?: { username: string; password: string }
}
export interface DiscordAssets {
largeImage?: string
largeText?: string
smallImage?: string
smallText?: string
export type ServerStatus = 'connected' | 'disconnected' | 'error'
export interface MangaFilters {
inLibrary?: boolean
status?: MangaStatus
tags?: string[]
unread?: boolean
sourceId?: string
}
export interface DiscordButton {
label: string
url: string
export type MangaStatus =
| 'ONGOING'
| 'COMPLETED'
| 'LICENSED'
| 'PUBLISHING_FINISHED'
| 'CANCELLED'
| 'ON_HIATUS'
export interface PaginatedResult<T> {
items: T[]
hasNextPage: boolean
total?: number
}
export interface DiscordPresence {
state?: string
details?: string
assets?: DiscordAssets
buttons?: DiscordButton[]
timestamps?: { start?: number; end?: number }
export interface MangaMeta {
customTitle?: string
customCover?: string
notes?: string
[key: string]: unknown
}
export interface AppUpdateInfo {
version: string
url: string
notes: string
export interface Page {
index: number
url: string
imageData?: string
}
export interface StorageInfo {
manga_bytes: number
total_bytes: number
free_bytes: number
path: string
export interface AboutServer {
name: string
version: string
buildType: string
buildTime: number
github: string
discord: string
}
export interface MigrateProgress {
done: number
total: number
current: string
export interface AboutWebUI {
channel: string
tag: string
updateTimestamp: number
}
export interface UpdateProgress {
downloaded: number
total: number | null
export interface DownloadItem {
chapterId: string
mangaId: string
chapterName: string
mangaTitle: string
thumbnailUrl?: string
progress: number
state: 'queued' | 'downloading' | 'finished' | 'error'
}
export interface ReleaseInfo {
tag_name: string
name: string
body: string
published_at: string
html_url: string
export interface UpdateResult {
mangaId: string
newChapters: number
}
export interface PlatformAdapter {
readonly platform: Platform
export interface LibraryUpdateProgress {
isRunning: boolean
finishedJobs: number
totalJobs: number
lastUpdated?: number
}
init(): Promise<void>
destroy(): Promise<void>
isSupported(feature: PlatformFeature): boolean
export interface ServerSecurity {
authMode: string
authUsername: string
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
getAppDir(): Promise<string>
export interface SetServerAuthInput {
authMode: string
authUsername: string
authPassword: string
}
loadStore(key: string): Promise<unknown>
saveStore(key: string, value: unknown): Promise<void>
export interface SetSocksProxyInput {
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
socksProxyPassword: string
}
storeCredential(key: string, value: string): Promise<void>
getCredential(key: string): Promise<string | null>
authenticateBiometric(): Promise<boolean>
export interface SetFlareSolverrInput {
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
readFile(path: string): Promise<Uint8Array>
writeFile(path: string, data: Uint8Array): Promise<void>
pickFolder(): Promise<string | null>
checkPathExists(path: string): Promise<boolean>
createDirectory(path: string): Promise<void>
openPath(path: string): Promise<void>
getDefaultDownloadsPath(): Promise<string>
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
migrateDownloads(src: string, dst: string): Promise<void>
getAutoBackupDir(): Promise<string>
export interface TrackRecordPatch {
status?: number
score?: number
lastChapterRead?: number
startDate?: string
finishDate?: string
private?: boolean
}
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
export interface RestoreStatus {
mangaProgress: number
state: string
totalManga: number
}
launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void>
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
export interface ValidateBackupResult {
missingSources: { id: string; name: string }[]
missingTrackers: { name: string }[]
}
setTitle(title: string): Promise<void>
minimize(): Promise<void>
maximize(): Promise<void>
close(): Promise<void>
toggleFullscreen(): Promise<void>
export interface ServerAdapter {
connect(config: ServerConfig): Promise<void>
getStatus(): Promise<ServerStatus>
getServerUrl(): string
setDiscordPresence(presence: DiscordPresence): Promise<void>
clearDiscordPresence(): Promise<void>
getManga(id: string, signal?: AbortSignal): Promise<Manga>
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
getMangasByGenre(filter: Record<string, unknown>, first: number, offset: number, signal?: AbortSignal): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }>
searchManga(query: string, sourceId?: string): Promise<Manga[]>
searchSource(sourceId: string, query: string, page?: number, signal?: AbortSignal): Promise<PaginatedResult<Manga>>
fetchManga(id: string): Promise<Manga>
addToLibrary(mangaId: string): Promise<void>
removeFromLibrary(mangaId: string): Promise<void>
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
deleteMangaMeta(id: string, key: string): Promise<void>
getVersion(): Promise<string>
openExternal(url: string): Promise<void>
checkForAppUpdate(): Promise<AppUpdateInfo | null>
installAppUpdate(tag: string): Promise<void>
restartApp(): Promise<void>
exitApp(): Promise<void>
listReleases(): Promise<ReleaseInfo[]>
getChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]>
getChapter(id: string): Promise<Chapter>
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
fetchChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]>
getRecentlyUpdated(): Promise<Chapter[]>
markChapterRead(id: string, read: boolean): Promise<void>
markChaptersRead(ids: string[], read: boolean): Promise<void>
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
deleteDownloadedChapters(ids: string[]): Promise<void>
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
deleteChapterMeta(chapterId: string, key: string): Promise<void>
clearMokuCache(): Promise<void>
clearSuwayomiCache(): Promise<void>
resetSuwayomiData(): Promise<void>
getAboutServer(): Promise<AboutServer>
getAboutWebUI(): Promise<AboutWebUI>
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
onUpdateLaunching(cb: () => void): Promise<() => void>
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
getDownloads(): Promise<DownloadItem[]>
getDownloadStatus(): Promise<DownloadStatus>
enqueueDownload(chapterId: string): Promise<void>
enqueueDownloads(chapterIds: string[]): Promise<void>
dequeueDownload(chapterId: string): Promise<void>
dequeueDownloads(chapterIds: string[]): Promise<void>
reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null>
clearDownloads(): Promise<void>
startDownloader(): Promise<DownloadStatus | null>
stopDownloader(): Promise<DownloadStatus | null>
getExtensions(): Promise<Extension[]>
installExtension(id: string): Promise<void>
uninstallExtension(id: string): Promise<void>
updateExtension(id: string): Promise<void>
updateExtensions(ids: string[]): Promise<void>
installExternalExtension(url: string): Promise<void>
getExtensionRepos(): Promise<string[]>
setExtensionRepos(repos: string[]): Promise<string[]>
getSources(): Promise<Source[]>
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
getSourceSettings(sourceId: string): Promise<unknown[]>
updateSourcePreference(sourceId: string, position: number, changeType: string, value: unknown): Promise<unknown[]>
getCategories(): Promise<Category[]>
createCategory(name: string): Promise<Category>
deleteCategory(id: number): Promise<void>
updateCategoryOrder(id: number, position: number): Promise<Category[]>
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
updateCategoryManga(categoryId: number): Promise<void>
getTrackers(): Promise<Tracker[]>
getAllTrackerRecords(): Promise<unknown[]>
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
searchTracker(trackerId: string, query: string): Promise<unknown[]>
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
unlinkTracker(recordId: string): Promise<void>
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
fetchTrackRecord(recordId: string): Promise<TrackRecord>
syncTracking(mangaId: string): Promise<void>
loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void>
loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void>
logoutTracker(trackerId: string): Promise<void>
getServerSecurity(): Promise<ServerSecurity>
setServerAuth(input: SetServerAuthInput): Promise<void>
setSocksProxy(input: SetSocksProxyInput): Promise<void>
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }>
setDownloadsPath(path: string): Promise<void>
setLocalSourcePath(path: string): Promise<void>
createBackup(): Promise<{ url: string }>
restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }>
validateBackup(file: File): Promise<ValidateBackupResult>
pollRestoreStatus(id: string): Promise<RestoreStatus>
clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
startLibraryUpdate(): Promise<void>
stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
clearPageCache(chapterId?: number): void
}
+1 -1
View File
@@ -188,7 +188,7 @@ class HistoryStore {
}
private async _persist() {
const bookmarks = (await import('$lib/state/reader.svelte')).readerState.bookmarks
const bookmarks = (await import('$lib/state/series.svelte')).seriesState.bookmarks
const markers = (await import('$lib/state/reader.svelte')).readerState.markers
await saveLibrary({
sessions: this.sessions,
+1 -1
View File
@@ -149,7 +149,7 @@ class LibraryState {
const f = this.tabFilters[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.totalChapters ?? 0) > (m.unreadCount ?? 0));
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
+15 -23
View File
@@ -1,7 +1,8 @@
import type { Manga, Chapter } from "$lib/types";
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { seriesState } from "$lib/state/series.svelte";
import { goto } from "$app/navigation";
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
@@ -30,12 +31,14 @@ export interface StripChapter {
}
class ReaderState {
activeManga = $state<Manga | null>(null);
activeChapter = $state<Chapter | null>(null);
activeChapterList = $state<Chapter[]>([]);
get activeManga() { return seriesState.activeManga; }
set activeManga(v: Manga | null) { seriesState.activeManga = v; }
get activeChapter() { return seriesState.activeChapter; }
set activeChapter(v: Chapter | null){ seriesState.activeChapter = v; }
pageUrls = $state<string[]>([]);
pageNumber = $state(1);
bookmarks = $state<BookmarkEntry[]>([]);
markers = $state<MarkerEntry[]>([]);
loading = $state(true);
@@ -77,19 +80,19 @@ class ReaderState {
containerWidth = $state(0);
readonly activeChapterList = $derived(seriesState.readerChapterList);
get settings() { return settingsState.settings; }
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
openReader(chapter: Chapter, manga?: Manga | null) {
const isChapterNav = this.activeChapter !== null;
this.activeChapter = chapter;
this.activeChapterList = chapterList;
this.activeChapter = chapter;
if (manga !== undefined) this.activeManga = manga;
goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav });
}
closeReader() {
this.activeChapter = null;
this.activeChapterList = [];
this.activeChapter = null;
history.back();
}
@@ -143,17 +146,6 @@ class ReaderState {
this.markerEditId = "";
}
addBookmark(entry: Omit<BookmarkEntry, "savedAt">) {
this.bookmarks = [
{ ...entry, savedAt: Date.now() },
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200);
}
removeBookmark(chapterId: number) {
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
}
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
const id = Math.random().toString(36).slice(2);
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
@@ -224,5 +216,5 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
export const readerState = new ReaderState();
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { readerState.openReader(ch, list, manga); }
export function closeReader() { readerState.closeReader(); }
export function openReader(ch: Chapter, manga?: Manga | null) { readerState.openReader(ch, manga); }
export function closeReader() { readerState.closeReader(); }
+209 -103
View File
@@ -1,81 +1,170 @@
import type { Manga, Chapter } from "$lib/types";
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
import type { MangaPrefs } from "$lib/types/settings";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { goto } from "$app/navigation";
import type { Manga, Chapter } from '$lib/types'
import type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history'
import type { MangaPrefs } from '$lib/types/settings'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { getAdapter } from '$lib/request-manager'
import { buildChapterList } from '$lib/components/series/lib/chapterList'
import { goto } from '$app/navigation'
export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
export type { MangaPrefs } from "$lib/types/settings";
export type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history'
export type { MangaPrefs } from '$lib/types/settings'
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: 'global',
preferredScanlator: '',
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
autoDownloadScanlators: [],
}
const CHAPTER_TTL_MS = 2 * 60 * 1000
class SeriesStore {
current = $state<Manga | null>(null);
loading = $state(false);
error = $state<string | null>(null);
activeManga = $state<Manga | null>(null)
previewManga = $state<Manga | null>(null)
activeChapter = $state<Chapter | null>(null)
bookmarks = $state<BookmarkEntry[]>([])
markers = $state<MarkerEntry[]>([])
acknowledgedUpdates = $state<Set<number>>(new Set())
chapters = $state<Chapter[]>([]);
chaptersLoading = $state(false);
chaptersError = $state<string | null>(null);
#rawChapters = $state<Map<number, Chapter[]>>(new Map())
#fetchedAt = new Map<number, number>()
#abortCtrls = new Map<number, AbortController>()
#loading = $state<Set<number>>(new Set())
#errors = $state<Map<number, string>>(new Map())
activeMangaId = $state<number | null>(null);
activeManga = $state<Manga | null>(null);
previewManga = $state<Manga | null>(null);
activeChapter = $state<Chapter | null>(null);
activeChapterList = $state<Chapter[]>([]);
bookmarks = $state<BookmarkEntry[]>([]);
markers = $state<MarkerEntry[]>([]);
acknowledgedUpdates = $state<Set<number>>(new Set());
readonly activeChapterList = $derived.by(() => {
const id = this.activeManga?.id
if (id == null) return []
const raw = this.#rawChapters.get(id) ?? []
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
const globals = settingsState.settings
return buildChapterList(raw, {
sortMode: globals.chapterSortMode,
sortDir: globals.chapterSortDir,
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator),
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter),
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist),
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce),
})
})
setActiveMangaId(next: number | null) { this.activeMangaId = next; }
setActiveManga(next: Manga | null) { this.activeManga = next; }
setPreviewManga(next: Manga | null) { this.previewManga = next; }
readonly readerChapterList = $derived.by(() => {
const id = this.activeManga?.id
if (id == null) return []
const raw = this.#rawChapters.get(id) ?? []
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
return buildChapterList(raw, {
sortMode: 'source',
sortDir: 'asc',
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator),
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter),
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist),
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce),
})
})
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
this.activeChapter = chapter;
this.activeChapterList = chapterList;
if (manga !== undefined) this.activeManga = manga;
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
chaptersFor(mangaId: number): Chapter[] { return this.#rawChapters.get(mangaId) ?? [] }
isLoadingChapters(mangaId: number) { return this.#loading.has(mangaId) }
chapterError(mangaId: number) { return this.#errors.get(mangaId) ?? null }
async loadChapters(mangaId: number, { force = false } = {}): Promise<void> {
const now = Date.now()
const stalest = this.#fetchedAt.get(mangaId) ?? 0
const fresh = !force && this.#rawChapters.has(mangaId) && now - stalest < CHAPTER_TTL_MS
if (fresh) return
this.#abortCtrls.get(mangaId)?.abort()
const ctrl = new AbortController()
this.#abortCtrls.set(mangaId, ctrl)
this.#loading = new Set([...this.#loading, mangaId])
this.#errors = new Map(this.#errors)
this.#errors.delete(mangaId)
try {
const adapter = getAdapter()
let nodes = await adapter.getChapters(String(mangaId), ctrl.signal)
if (!ctrl.signal.aborted && nodes.length === 0) {
const fetched = await adapter.fetchChapters(String(mangaId), ctrl.signal)
if (!ctrl.signal.aborted) nodes = fetched
}
if (ctrl.signal.aborted) return
this.#rawChapters = new Map(this.#rawChapters).set(mangaId, nodes)
this.#fetchedAt.set(mangaId, Date.now())
} catch (e: unknown) {
if ((e as { name?: string }).name === 'AbortError') return
const msg = e instanceof Error ? e.message : String(e)
this.#errors = new Map(this.#errors).set(mangaId, msg)
} finally {
if (!ctrl.signal.aborted) {
const next = new Set(this.#loading)
next.delete(mangaId)
this.#loading = next
}
}
}
invalidateChapters(mangaId: number) {
this.#fetchedAt.delete(mangaId)
}
patchChapters(mangaId: number, updater: (chapters: Chapter[]) => Chapter[]) {
const current = this.#rawChapters.get(mangaId)
if (!current) return
this.#rawChapters = new Map(this.#rawChapters).set(mangaId, updater(current))
}
setActiveManga(manga: Manga | null) { this.activeManga = manga }
setPreviewManga(manga: Manga | null) { this.previewManga = manga }
openReaderForChapter(chapter: Chapter, manga?: Manga | null) {
if (manga !== undefined) this.activeManga = manga
const mangaId = this.activeManga?.id
if (!mangaId) return
const list = this.readerChapterList
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
const ahead = (prefs.downloadAhead ?? DEFAULT_MANGA_PREFS.downloadAhead) as number
if (ahead > 0) {
const idx = list.findIndex(c => c.id === chapter.id)
if (idx >= 0) {
const toQueue = list
.slice(idx + 1, idx + 1 + ahead)
.filter(c => !c.downloaded && !c.read)
.map(c => String(c.id))
if (toQueue.length) getAdapter().enqueueDownloads(toQueue).catch(console.error)
}
}
this.activeChapter = chapter
goto(`/reader/${mangaId}/${chapter.id}`)
}
closeReader() {
this.activeChapter = null;
this.activeChapterList = [];
this.activeChapter = null
}
acknowledgeUpdate(mangaId: number) {
if (this.acknowledgedUpdates.has(mangaId)) return;
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]);
if (this.acknowledgedUpdates.has(mangaId)) return
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId])
}
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
this.bookmarks = [
{ ...entry, savedAt: Date.now(), label },
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
].slice(0, 200);
}
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); }
clearBookmarks() { this.bookmarks = []; }
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId); }
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
const id = Math.random().toString(36).slice(2);
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
return id;
}
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
}
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id); }
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page); }
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {};
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]
}
setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
@@ -84,49 +173,66 @@ class SeriesStore {
...settingsState.settings.mangaPrefs,
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
})
}
get settings() { return settingsState.settings; }
addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
this.bookmarks = [
{ ...entry, savedAt: Date.now(), label },
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
].slice(0, 200)
}
/** Sets the single "resume" bookmark for a manga, replacing any bookmark
* that exists for that manga in a different chapter. */
setBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
const other = this.bookmarks.find(b => b.mangaId === entry.mangaId && b.chapterId !== entry.chapterId)
if (other) this.removeBookmark(other.chapterId)
this.addBookmark(entry, label)
}
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId) }
clearBookmarks() { this.bookmarks = [] }
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId) }
addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string {
const id = Math.random().toString(36).slice(2)
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }]
return id
}
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) {
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m)
}
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id) }
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page) }
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId) }
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId) }
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId) }
get settings() { return settingsState.settings }
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
sortMode: "source",
sortDir: "asc",
preferredScanlator: "",
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
autoDownload: false,
downloadAhead: 0,
maxKeepChapters: 0,
deleteOnRead: false,
deleteDelayHours: 0,
pauseUpdates: false,
refreshInterval: "global",
coverUrl: "",
};
export const seriesState = new SeriesStore()
export const seriesStore = seriesState
export const seriesState = new SeriesStore();
export const seriesStore = seriesState;
export function setActiveMangaId(next: number | null) { seriesState.setActiveMangaId(next); }
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next); }
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next); }
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { seriesState.openReader(ch, list, manga); }
export function closeReader() { seriesState.closeReader(); }
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { seriesState.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId); }
export function clearBookmarks() { seriesState.clearBookmarks(); }
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId); }
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return seriesState.addMarker(entry); }
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { seriesState.updateMarker(id, patch); }
export function removeMarker(id: string) { seriesState.removeMarker(id); }
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page); }
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId); }
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId); }
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId); }
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key); }
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) { seriesState.setPref(mangaId, key, value); }
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next) }
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next) }
export function openReaderForChapter(ch: Chapter, manga?: Manga | null) { seriesState.openReaderForChapter(ch, manga) }
export function closeReader() { seriesState.closeReader() }
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId) }
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.addBookmark(entry, label) }
export function setBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.setBookmark(entry, label) }
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId) }
export function clearBookmarks() { seriesState.clearBookmarks() }
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId) }
export function addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string { return seriesState.addMarker(entry) }
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) { seriesState.updateMarker(id, patch) }
export function removeMarker(id: string) { seriesState.removeMarker(id) }
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page) }
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId) }
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId) }
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId) }
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key) }
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, v: MangaPrefs[K]) { seriesState.setPref(mangaId, key, v) }
+1 -11
View File
@@ -19,7 +19,6 @@ class TrackingState {
loadingFor: Set<number> = $state(new Set())
error: string | null = $state(null)
// Legacy flat fields kept for request-manager/tracking.ts compatibility
trackers: Tracker[] = $state([])
loading: boolean = $state(false)
syncing: boolean = $state(false)
@@ -55,7 +54,6 @@ class TrackingState {
}))
}
// ── Per-manga load ──────────────────────────────────────────────────────────
async loadForManga(mangaId: number) {
if (this.loadingFor.has(mangaId)) return
@@ -78,15 +76,13 @@ class TrackingState {
}
}
// ── Global load (tracking page) ─────────────────────────────────────────────
async loadAll() {
this.loadingAll = true
this.error = null
try {
const trackers = await getAdapter().getAllTrackerRecords() as TrackerWithRecords[]
this.allTrackers = trackers
this.trackers = trackers // keep flat field in sync
this.trackers = trackers
for (const tracker of trackers.filter((t) => t.isLoggedIn)) {
for (const record of tracker.trackRecords.nodes) {
@@ -104,8 +100,6 @@ class TrackingState {
}
}
// ── Field updates ───────────────────────────────────────────────────────────
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
const fresh = await getAdapter().updateTrackRecord(String(record.id), { status })
this.patchFor(mangaId, fresh)
@@ -134,7 +128,6 @@ class TrackingState {
}))
}
// ── Remote sync ─────────────────────────────────────────────────────────────
async syncFromRemote(
mangaId: number,
@@ -168,7 +161,6 @@ class TrackingState {
)
}
// ── Read/unread sync ────────────────────────────────────────────────────────
async updateFromRead(
mangaId: number,
@@ -232,7 +224,6 @@ class TrackingState {
}
}
// ── Boot sync ───────────────────────────────────────────────────────────────
async bootSync() {
if (!settingsState.settings.trackerSyncBack) return
@@ -297,7 +288,6 @@ class TrackingState {
this.byManga = next
}
// ── Status helpers ──────────────────────────────────────────────────────────
private _statusesFor(trackerId: number): { value: number; name: string }[] {
return this.allTrackers.find((t) => t.id === trackerId)?.statuses ?? []
+2 -1
View File
@@ -1,4 +1,5 @@
export type { Manga, MangaDetail, Category, ChapterRef } from './manga'
export type { Chapter } from './chapter'
export type { Extension, Source } from './extension'
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
export type { Settings, MangaPrefs, ContentLevel } from './settings'
+5 -2
View File
@@ -49,5 +49,8 @@ export interface Manga {
firstUnreadChapter?: ChapterRef | null
highestNumberedChapter?: ChapterRef | null
source?: { id: string; name: string; displayName: string } | null
}
source?: { id: string; name: string; displayName: string; isNsfw?: boolean } | null
chapters?: { totalCount: number }
}
export type MangaDetail = Manga