mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
339 Commits
v0.3.0
..
d9a9427e3b
| Author | SHA1 | Date | |
|---|---|---|---|
| d9a9427e3b | |||
| ae5d9748c7 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bee8117aac | |||
| 0bea9c22cb | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e | |||
| 83711c155d | |||
| 3702a25813 | |||
| a71cc719ba | |||
| 1801fecdbb | |||
| 0cd799f450 | |||
| 5dab7761bc | |||
| 552a11a517 | |||
| c8ec6d6b90 | |||
| daaeae00fe | |||
| 79cb2f7c56 | |||
| 4d3dfdbec6 | |||
| 78573eacb1 | |||
| 1bb7da3b22 | |||
| dd0cf9372d | |||
| 50928c6343 | |||
| 170493aa71 | |||
| c009bd71fc | |||
| 4df7f416a7 | |||
| 63209cb828 | |||
| 2c1391c378 | |||
| 41fd4a820c | |||
| 9f3c6d2ac3 | |||
| f5b3f76b5d | |||
| 528f966b1f | |||
| 75e8bc5986 | |||
| 8123053a40 | |||
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 | |||
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead |
@@ -1,66 +0,0 @@
|
|||||||
name: Build AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version tag (e.g. 0.1.0)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
|
||||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
|
||||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
patchelf \
|
|
||||||
file
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Setup Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: pnpm tauri build --bundles appimage
|
|
||||||
env:
|
|
||||||
NO_STRIP: "true"
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
|
||||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: Build Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Linux x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libfuse2
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Linux x64)
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
|
||||||
|
-o suwayomi-linux.tar.gz
|
||||||
|
|
||||||
|
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
|
||||||
|
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
||||||
|
JAVA="suwayomi-extracted/jre/bin/java"
|
||||||
|
CATCH="suwayomi-extracted/bin/catch_abort.so"
|
||||||
|
|
||||||
|
for f in "$JAR" "$JAVA" "$CATCH"; do
|
||||||
|
if [ ! -e "$f" ]; then
|
||||||
|
echo "ERROR: expected file not found: $f"
|
||||||
|
find suwayomi-extracted -type f | head -40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
|
||||||
|
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||||
|
|
||||||
|
- name: Stage Linux launcher sidecar
|
||||||
|
run: |
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
|
||||||
|
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||||
|
env:
|
||||||
|
NO_STRIP: "true"
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
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'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||||
|
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||||
|
|
||||||
|
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
||||||
|
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (macOS)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi binaries
|
||||||
|
run: |
|
||||||
|
download_suwayomi() {
|
||||||
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||||
|
-o "${outdir}.tar.gz"
|
||||||
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
|
mkdir -p "${outdir}"
|
||||||
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
|
}
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||||
|
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||||
|
"suwayomi-arm64"
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||||
|
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||||
|
"suwayomi-x64"
|
||||||
|
|
||||||
|
- name: Stage Suwayomi sidecars
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
stage_arch() {
|
||||||
|
local srcdir="$1"
|
||||||
|
local arch="$2"
|
||||||
|
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
|
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
|
|
||||||
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||||
|
|
||||||
|
cp -r "$srcdir" "$bundle_dest"
|
||||||
|
|
||||||
|
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||||
|
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||||
|
chmod +x "$sidecar"
|
||||||
|
echo "Staged sidecar: $sidecar"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Swap bundle for aarch64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Swap bundle for x86_64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
# Wait for the Windows workflow to have created the draft release
|
||||||
|
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'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
|
||||||
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Windows x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Windows x64)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||||
|
-o suwayomi-windows.zip
|
||||||
|
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||||
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
|
- name: Extract Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
|
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||||
|
cp -r "$INNER"/. suwayomi-extracted/
|
||||||
|
else
|
||||||
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
|
find suwayomi-extracted -type f | head -50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found"
|
||||||
|
find suwayomi-extracted -type f | head -50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Validate staging
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||||
|
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||||
|
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||||
|
echo "Staging OK"
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Delete existing draft release if present
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
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 }}" and .draft == true) | .id')
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
|
echo "Deleted draft release and tag"
|
||||||
|
else
|
||||||
|
echo "No existing draft release found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app + create draft release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: v${{ github.event.inputs.version }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** Download `moku.flatpak`
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
+22
-8
@@ -1,26 +1,33 @@
|
|||||||
# --- Build Artifacts ---
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
suwayomi-raw/
|
||||||
|
suwayomi-windows.zip
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# --- Nix ---
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
# --- Logs ---
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# --- IDEs & OS ---
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -30,12 +37,19 @@ yarn-error.log*
|
|||||||
*.sln
|
*.sln
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# --- Tauri specific ---
|
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/
|
./flatpak-builder
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright [2026] [@Youwes09]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.2.0
|
pkgver=0.9.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Youwes09/Moku"
|
url="https://github.com/moku-project/Moku"
|
||||||
license=('Apache 2.0')
|
license=('Apache-2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -13,34 +13,46 @@ depends=(
|
|||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
'rust'
|
'rust'
|
||||||
'cargo'
|
|
||||||
'nodejs'
|
|
||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
optdepends=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
'discord: Discord rich presence'
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
)
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
options=('!strip')
|
||||||
|
source=(
|
||||||
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
|
)
|
||||||
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
|
sha256sums=(
|
||||||
|
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||||
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
|
)
|
||||||
|
b2sums=(
|
||||||
|
'SKIP'
|
||||||
|
'SKIP'
|
||||||
)
|
)
|
||||||
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
|
||||||
|
mkdir -p src-tauri/.cargo
|
||||||
|
cat > src-tauri/.cargo/config.toml << 'EOF'
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
|
||||||
# Repack dist for Tauri
|
fixed_cflags="${fixed_cflags/-flto=auto/}"
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
|
||||||
# Build Tauri binary
|
CFLAGS="$fixed_cflags" \
|
||||||
|
CXXFLAGS="$fixed_cxxflags" \
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -49,21 +61,14 @@ build() {
|
|||||||
package() {
|
package() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Moku binary
|
|
||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
# Bundled JRE
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
# Suwayomi server jar
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
# tachidesk-server wrapper script
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -74,22 +79,22 @@ server.autoDownloadNewChapters = false
|
|||||||
server.globalUpdateInterval = 12
|
server.globalUpdateInterval = 12
|
||||||
server.maxSourcesInParallel = 6
|
server.maxSourcesInParallel = 6
|
||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
CONF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
@@ -100,26 +105,25 @@ unset WAYLAND_DISPLAY
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
exec /usr/lib/moku/jre/bin/java \
|
exec java \
|
||||||
-Djava.awt.headless=true \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
-Dsun.awt.disablegui=true \
|
-Dsun.awt.disablegui=true \
|
||||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
LAUNCHER
|
||||||
|
|
||||||
# Desktop entry and icons
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
install -Dm644 LICENSE \
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,42 @@
|
|||||||
<div align="center">
|
# sv
|
||||||
<img src="src/assets/rounded-logo.png" width="96" />
|
|
||||||
<h1>Moku</h1>
|
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
|
||||||
|
|
||||||
<table>
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
## Creating a project
|
||||||
|
|
||||||
## Features
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
### Reader
|
```sh
|
||||||
- **Single**, **double-page**, and **longstrip** reading modes
|
# create a new project
|
||||||
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
|
npx sv create my-app
|
||||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
|
||||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
|
||||||
- RTL / LTR reading direction toggle
|
|
||||||
- Configurable page gaps
|
|
||||||
- Full keyboard navigation with rebindable keybinds
|
|
||||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
|
||||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
|
||||||
- Auto-mark chapters as read when the last page is reached
|
|
||||||
|
|
||||||
### Library
|
|
||||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
|
||||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
|
||||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
|
||||||
- In-line search
|
|
||||||
- Context menu: open, add/remove from library
|
|
||||||
|
|
||||||
### Series Detail
|
|
||||||
- Cover, author, artist, status badge, genres, and synopsis
|
|
||||||
- Read progress bar with percentage
|
|
||||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
|
||||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
|
||||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
|
||||||
- Sort by newest or oldest first
|
|
||||||
- Jump-to-chapter input
|
|
||||||
- Bulk download menu: from current chapter, unread only, or all
|
|
||||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
|
||||||
- Collapsible source details panel with source ID, language, and source migration
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- Cross-source search running up to 3 concurrent requests
|
|
||||||
- Language filter bar (preferred language default, per-language, or all)
|
|
||||||
- Results grouped by source with skeleton loading states
|
|
||||||
|
|
||||||
### Sources & Extensions
|
|
||||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
|
||||||
- Extension manager: install, update, remove, and install from external APK URL
|
|
||||||
- Repo refresh with update count badge
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- Download queue with live progress
|
|
||||||
|
|
||||||
### History
|
|
||||||
- Reading history grouped by day with relative timestamps
|
|
||||||
- Per-entry thumbnail, chapter name, and last-read page
|
|
||||||
- Full-text search across titles and chapter names
|
|
||||||
- One-click clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
|
||||||
|
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
**Nix (recommended)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix run github:Youwes09/moku
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
```nix
|
```sh
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
# recreate this project
|
||||||
|
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
## Developing
|
||||||
|
|
||||||
```bash
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
```sh
|
||||||
nix build
|
npm run dev
|
||||||
./result/bin/moku
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Building
|
||||||
|
|
||||||
## Development
|
To create a production version of your app:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
nix develop
|
npm run build
|
||||||
pnpm install
|
|
||||||
pnpm tauri:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
---
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
|
||||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
|
||||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
|
||||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Disclaimer
|
|
||||||
|
|
||||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
|
||||||
|
|||||||
@@ -1,89 +1,39 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
- Moku-Share allows exporting of Manga
|
||||||
5. Investigate "egl:failed to create dri2 screen"
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
Bugs:
|
Minor Revisions:
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
|
||||||
-
|
Priority Bugs:
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
- Fix Library-Refresh System (TESTING)
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
|
||||||
- Add Back after Search & Clear on Search
|
|
||||||
- Add as Package in Nix Flake & Check Later
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
|
|
||||||
|
- Suwayomi RESET
|
||||||
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
- Fix Mangafire Main Dispatcher Issue
|
Pending/On-Hold:
|
||||||
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
|
- Change Auto-Link Threshold
|
||||||
|
- Fix Auto-Link De-dupe for Images
|
||||||
|
- Optimize Auto-Link Latency (IP)
|
||||||
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
In-Progress:
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
- Clean up Migrate Model to be more initutive
|
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||||
|
- Note User's have to always install extensions manually
|
||||||
|
- Create "Missing Source" for Manga
|
||||||
|
|
||||||
Features:
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
- Add PDF Textbook Support
|
|
||||||
- Major revision to disable entire manga-subsection and use as
|
|
||||||
solely as a reader/document launcher.
|
|
||||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
-
|
|
||||||
|
|
||||||
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
Big Revisions:
|
Notes from last time:
|
||||||
0. Expand into fully-fledged reader, with modular manga support
|
|
||||||
1. Anime & Novel Support
|
|
||||||
2. Tracker Support
|
|
||||||
3. Cloudflare Bypass Enable Support
|
|
||||||
4. macOS Support (feasible)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Testing:
|
|
||||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
|
||||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
|
||||||
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
|
||||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
|
||||||
7. Fix Scaling (100 = 125% and so forth)
|
|
||||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
|
||||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
|
||||||
11. Reader & UI needs download and other Notifications
|
|
||||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
|
||||||
- Add Refresh Details on Series Details.
|
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
|
||||||
- Remove Series Detail Mark Read & Unread
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
|
|||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||||
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
|
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||||
|
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||||
|
import { applyTheme } from "@core/theme";
|
||||||
|
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||||
|
import { checkForUpdateSilently } from "@core/updater";
|
||||||
|
import Layout from "@shared/chrome/Layout.svelte";
|
||||||
|
import Reader from "@features/reader/components/Reader.svelte";
|
||||||
|
import Settings from "@features/settings/components/Settings.svelte";
|
||||||
|
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||||
|
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||||
|
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||||
|
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||||
|
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||||
|
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
void platform();
|
||||||
|
|
||||||
|
let appReady = $state(false);
|
||||||
|
let idle = $state(false);
|
||||||
|
let devSplash = $state(false);
|
||||||
|
|
||||||
|
let themeEditorOpen = $state(false);
|
||||||
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let closeDialogOpen = $state(false);
|
||||||
|
let closeRemember = $state(false);
|
||||||
|
|
||||||
|
function openThemeEditor(id?: string | null) {
|
||||||
|
themeEditorEditId = id ?? null;
|
||||||
|
themeEditorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThemeEditor() {
|
||||||
|
themeEditorOpen = false;
|
||||||
|
themeEditorEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doQuit() {
|
||||||
|
if (store.settings.autoStartServer) {
|
||||||
|
await Promise.race([
|
||||||
|
invoke("kill_server").catch(() => {}),
|
||||||
|
new Promise(res => setTimeout(res, 2000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await invoke("exit_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doHide() {
|
||||||
|
await win.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloseRequested() {
|
||||||
|
const action = store.settings.closeAction ?? "ask";
|
||||||
|
if (action === "tray") { await doHide(); return; }
|
||||||
|
if (action === "quit") { await doQuit(); return; }
|
||||||
|
closeDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmClose(choice: "tray" | "quit") {
|
||||||
|
closeDialogOpen = false;
|
||||||
|
if (closeRemember) updateSettings({ closeAction: choice });
|
||||||
|
closeRemember = false;
|
||||||
|
if (choice === "tray") await doHide();
|
||||||
|
else await doQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { void store.settings.theme; applyTheme(); });
|
||||||
|
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
||||||
|
$effect(() => mountZoomKey());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
return mountIdleDetection(
|
||||||
|
() => { idle = true; },
|
||||||
|
() => { if (idle) idle = false; },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!appReady) return;
|
||||||
|
downloadStore.poll();
|
||||||
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
return () => clearInterval(dlInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store.settings.discordRpc) {
|
||||||
|
initRpc();
|
||||||
|
} else {
|
||||||
|
clearReading();
|
||||||
|
destroyRpc();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = downloadStore.queue.slice();
|
||||||
|
downloadStore.detectTransitions(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
|
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||||
|
|
||||||
|
applyZoom();
|
||||||
|
|
||||||
|
store.isFullscreen = await win.isFullscreen();
|
||||||
|
|
||||||
|
const unlistenResize = await win.onResized(async () => {
|
||||||
|
store.isFullscreen = await win.isFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenScale = await win.onScaleChanged(async () => {
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||||
|
|
||||||
|
await initStore();
|
||||||
|
startProbe();
|
||||||
|
|
||||||
|
if (store.settings.autoStartServer) {
|
||||||
|
invoke<void>("spawn_server", {
|
||||||
|
binary: store.settings.serverBinary,
|
||||||
|
webUiEnabled: store.settings.suwayomiWebUI ?? false,
|
||||||
|
}).catch((err: any) => {
|
||||||
|
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||||
|
else console.warn("Could not start server:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
|
"download-progress",
|
||||||
|
e => setActiveDownloads(e.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopProbe();
|
||||||
|
unlistenResize();
|
||||||
|
unlistenScale();
|
||||||
|
unlistenDownload();
|
||||||
|
unlistenClose();
|
||||||
|
destroyRpc();
|
||||||
|
delete (window as any).__mokuShowSplash;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if devSplash}
|
||||||
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
|
|
||||||
|
{:else if !appReady && !boot.loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||||
|
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||||
|
showCards={store.settings.splashCards ?? true}
|
||||||
|
onReady={() => { appReady = true; }}
|
||||||
|
onRetry={retryBoot}
|
||||||
|
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||||
|
|
||||||
|
{:else if boot.loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { appReady = true; }} />
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#if idle && !store.activeChapter}
|
||||||
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { idle = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if boot.sessionExpired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div id="app-shell" class="root">
|
||||||
|
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||||
|
<div class="content">
|
||||||
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
|
</div>
|
||||||
|
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||||
|
{#if themeEditorOpen}
|
||||||
|
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||||
|
{/if}
|
||||||
|
<MangaPreview />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if closeDialogOpen}
|
||||||
|
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||||
|
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="close-header">
|
||||||
|
<p class="close-title">Close Moku?</p>
|
||||||
|
<p class="close-sub">Choose how the app should exit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="close-actions">
|
||||||
|
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||||
|
<span class="close-btn-label">Minimize to Tray</span>
|
||||||
|
<span class="close-btn-desc">Keep running in the background</span>
|
||||||
|
</button>
|
||||||
|
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||||
|
<span class="close-btn-label">Quit</span>
|
||||||
|
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||||
|
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||||
|
<span class="close-remember-label">Remember my choice</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
.content { flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
.close-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-dialog {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
width: 300px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||||
|
0 20px 60px rgba(0,0,0,0.65),
|
||||||
|
0 6px 20px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
|
||||||
|
.close-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
||||||
|
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
||||||
|
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
||||||
|
|
||||||
|
.close-btn-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn-desc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-1) 0 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.close-remember-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-faint);
|
||||||
|
transition: transform var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-remember-toggle.on .close-remember-thumb {
|
||||||
|
transform: translateX(12px);
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
|
||||||
|
import { boot } from "@store/boot.svelte";
|
||||||
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
|
type ReauthResolver = () => void;
|
||||||
|
let _reauthQueue: ReauthResolver[] = [];
|
||||||
|
|
||||||
|
export function notifyReauthSuccess() {
|
||||||
|
const queue = _reauthQueue;
|
||||||
|
_reauthQueue = [];
|
||||||
|
queue.forEach(resolve => resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReauth(): Promise<void> {
|
||||||
|
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerUrl(): string {
|
||||||
|
const url = store.settings.serverUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plainThumbUrl(path: string): string {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
return `${getServerUrl()}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImageUrl(path: string): Promise<string> {
|
||||||
|
if (!path) return "";
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "NONE") return url;
|
||||||
|
return getBlobUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
|
interface GQLResponse<T> {
|
||||||
|
data: T;
|
||||||
|
errors?: { message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 3,
|
||||||
|
delayMs = 300,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
try {
|
||||||
|
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return res;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.authRequired) throw e;
|
||||||
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (e instanceof AuthRequiredError) throw e;
|
||||||
|
if (i === retries - 1) throw e;
|
||||||
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImage(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ src: string; revoke: () => void }> {
|
||||||
|
if (!path) return { src: "", revoke: () => {} };
|
||||||
|
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "NONE") return { src: url, revoke: () => {} };
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
||||||
|
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
return { src, revoke: () => URL.revokeObjectURL(src) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gql<T>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
const tryRefreshAndRetry = async (): Promise<T | null> => {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode !== "UI_LOGIN" || boot.skipped) return null;
|
||||||
|
const refreshed = await refreshUiAccessToken(true);
|
||||||
|
if (!refreshed) return null;
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
};
|
||||||
|
|
||||||
|
const attempt = async (): Promise<T> => {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`${getServerUrl()}/api/graphql`,
|
||||||
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const retried = await tryRefreshAndRetry();
|
||||||
|
if (retried) return retried;
|
||||||
|
}
|
||||||
|
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (json.errors?.length) {
|
||||||
|
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||||
|
if (isAuthError && !boot.skipped) {
|
||||||
|
const retried = await tryRefreshAndRetry();
|
||||||
|
if (retried) return retried;
|
||||||
|
|
||||||
|
boot.sessionExpired = true;
|
||||||
|
boot.loginRequired = true;
|
||||||
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
await waitForReauth();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
throw new Error(json.errors[0].message);
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./client";
|
||||||
|
export * from "./queries/manga";
|
||||||
|
export * from "./queries/chapters";
|
||||||
|
export * from "./queries/downloads";
|
||||||
|
export * from "./queries/extensions";
|
||||||
|
export * from "./queries/tracking";
|
||||||
|
export * from "./mutations/manga";
|
||||||
|
export * from "./mutations/chapters";
|
||||||
|
export * from "./mutations/downloads";
|
||||||
|
export * from "./mutations/extensions";
|
||||||
|
export * from "./mutations/tracking";
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const FETCH_CHAPTERS = `
|
||||||
|
mutation FetchChapters($mangaId: Int!) {
|
||||||
|
fetchChapters(input: { mangaId: $mangaId }) {
|
||||||
|
chapters {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_CHAPTER_PAGES = `
|
||||||
|
mutation FetchChapterPages($chapterId: Int!) {
|
||||||
|
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTER_READ = `
|
||||||
|
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||||
|
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||||
|
chapter { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTERS_READ = `
|
||||||
|
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||||
|
chapters { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CHAPTERS_PROGRESS = `
|
||||||
|
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
|
||||||
|
chapters { id isRead isBookmarked lastPageRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||||
|
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||||
|
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||||
|
chapters { id isDownloaded }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_CHAPTER_META = `
|
||||||
|
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
|
||||||
|
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CHAPTER_META = `
|
||||||
|
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
|
||||||
|
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const QUEUE_FRAGMENT = `
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_DOWNLOAD = `
|
||||||
|
mutation EnqueueDownload($chapterId: Int!) {
|
||||||
|
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_DOWNLOAD = `
|
||||||
|
mutation DequeueDownload($chapterId: Int!) {
|
||||||
|
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
dequeueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REORDER_DOWNLOAD = `
|
||||||
|
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
|
||||||
|
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const START_DOWNLOADER = `
|
||||||
|
mutation StartDownloader {
|
||||||
|
startDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const STOP_DOWNLOADER = `
|
||||||
|
mutation StopDownloader {
|
||||||
|
stopDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_DOWNLOADER = `
|
||||||
|
mutation ClearDownloader {
|
||||||
|
clearDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_SOURCE_MANGA = `
|
||||||
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
|
mangas { id title thumbnailUrl inLibrary }
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_DOWNLOADS_PATH = `
|
||||||
|
mutation SetDownloadsPath($path: String!) {
|
||||||
|
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||||
|
settings { downloadsPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_LOCAL_SOURCE_PATH = `
|
||||||
|
mutation SetLocalSourcePath($path: String!) {
|
||||||
|
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||||
|
settings { localSourcePath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
export const FETCH_EXTENSIONS = `
|
||||||
|
mutation FetchExtensions {
|
||||||
|
fetchExtensions(input: {}) {
|
||||||
|
extensions {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTENSION = `
|
||||||
|
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extension { apkName pkgName name isInstalled hasUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTENSIONS = `
|
||||||
|
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extensions { apkName pkgName name isInstalled hasUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||||
|
mutation InstallExternalExtension($url: String!) {
|
||||||
|
installExternalExtension(input: { extensionUrl: $url }) {
|
||||||
|
extension { apkName pkgName name isInstalled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_SOURCE_PREFERENCE = `
|
||||||
|
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
||||||
|
updateSourcePreference(input: { source: $source, change: $change }) {
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOURCE_METAS = `
|
||||||
|
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
||||||
|
setSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_METAS = `
|
||||||
|
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
||||||
|
deleteSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_SOURCE_METADATA = `
|
||||||
|
mutation UpdateSourceMetadata(
|
||||||
|
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPreUpdateDeletions: Boolean!
|
||||||
|
$updateInput: SetSourceMetasInput!
|
||||||
|
$hasUpdates: Boolean!
|
||||||
|
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPostUpdateDeletions: Boolean!
|
||||||
|
$migrateInput: SetSourceMetasInput!
|
||||||
|
$isMigration: Boolean!
|
||||||
|
) {
|
||||||
|
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOURCE_META = `
|
||||||
|
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||||
|
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_META = `
|
||||||
|
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
||||||
|
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_CATEGORY_META = `
|
||||||
|
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
|
||||||
|
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY_META = `
|
||||||
|
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
|
||||||
|
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_GLOBAL_META = `
|
||||||
|
mutation SetGlobalMeta($key: String!, $value: String!) {
|
||||||
|
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_GLOBAL_META = `
|
||||||
|
mutation DeleteGlobalMeta($key: String!) {
|
||||||
|
deleteGlobalMeta(input: { key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_CACHED_IMAGES = `
|
||||||
|
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
||||||
|
clearCachedImages(input: {
|
||||||
|
cachedPages: $cachedPages
|
||||||
|
cachedThumbnails: $cachedThumbnails
|
||||||
|
downloadedThumbnails: $downloadedThumbnails
|
||||||
|
}) {
|
||||||
|
cachedPages cachedThumbnails downloadedThumbnails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESET_SETTINGS = `
|
||||||
|
mutation ResetSettings {
|
||||||
|
resetSettings(input: {}) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_EXTENSION_REPOS = `
|
||||||
|
mutation SetExtensionRepos($repos: [String!]!) {
|
||||||
|
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SERVER_AUTH = `
|
||||||
|
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||||
|
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||||
|
settings { authMode authUsername }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOCKS_PROXY = `
|
||||||
|
mutation SetSocksProxy(
|
||||||
|
$socksProxyEnabled: Boolean!
|
||||||
|
$socksProxyHost: String!
|
||||||
|
$socksProxyPort: String!
|
||||||
|
$socksProxyVersion: Int!
|
||||||
|
$socksProxyUsername: String!
|
||||||
|
$socksProxyPassword: String!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
socksProxyEnabled: $socksProxyEnabled
|
||||||
|
socksProxyHost: $socksProxyHost
|
||||||
|
socksProxyPort: $socksProxyPort
|
||||||
|
socksProxyVersion: $socksProxyVersion
|
||||||
|
socksProxyUsername: $socksProxyUsername
|
||||||
|
socksProxyPassword: $socksProxyPassword
|
||||||
|
}}) {
|
||||||
|
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_FLARESOLVERR = `
|
||||||
|
mutation SetFlareSolverr(
|
||||||
|
$flareSolverrEnabled: Boolean!
|
||||||
|
$flareSolverrUrl: String!
|
||||||
|
$flareSolverrTimeout: Int!
|
||||||
|
$flareSolverrSessionName: String!
|
||||||
|
$flareSolverrSessionTtl: Int!
|
||||||
|
$flareSolverrAsResponseFallback: Boolean!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
flareSolverrEnabled: $flareSolverrEnabled
|
||||||
|
flareSolverrUrl: $flareSolverrUrl
|
||||||
|
flareSolverrTimeout: $flareSolverrTimeout
|
||||||
|
flareSolverrSessionName: $flareSolverrSessionName
|
||||||
|
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
||||||
|
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
||||||
|
}}) {
|
||||||
|
settings {
|
||||||
|
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||||
|
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
export const FETCH_MANGA = `
|
||||||
|
mutation FetchManga($id: Int!) {
|
||||||
|
fetchManga(input: { id: $id }) {
|
||||||
|
manga {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
source { id name displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA = `
|
||||||
|
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||||
|
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
manga { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS = `
|
||||||
|
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||||
|
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
mangas { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA_CATEGORIES = `
|
||||||
|
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
manga { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS_CATEGORIES = `
|
||||||
|
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
mangas { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_CATEGORY = `
|
||||||
|
mutation CreateCategory($name: String!) {
|
||||||
|
createCategory(input: { name: $name }) {
|
||||||
|
category { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY = `
|
||||||
|
mutation UpdateCategory($id: Int!, $name: String) {
|
||||||
|
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||||
|
category { id name order }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORIES = `
|
||||||
|
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
|
||||||
|
updateCategories(input: { ids: $ids, patch: $patch }) {
|
||||||
|
categories { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY = `
|
||||||
|
mutation DeleteCategory($id: Int!) {
|
||||||
|
deleteCategory(input: { categoryId: $id }) {
|
||||||
|
category { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_ORDER = `
|
||||||
|
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||||
|
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||||
|
categories { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_MANGA = `
|
||||||
|
mutation UpdateCategoryManga($categoryId: Int!) {
|
||||||
|
updateCategoryManga(input: { categoryId: $categoryId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY = `
|
||||||
|
mutation UpdateLibrary {
|
||||||
|
updateLibrary(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY_MANGA = `
|
||||||
|
mutation UpdateLibraryManga($mangaId: Int!) {
|
||||||
|
updateLibraryManga(input: { mangaId: $mangaId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_STOP = `
|
||||||
|
mutation UpdateStop {
|
||||||
|
updateStop(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_BACKUP = `
|
||||||
|
mutation CreateBackup {
|
||||||
|
createBackup(input: {}) { url }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESTORE_BACKUP = `
|
||||||
|
mutation RestoreBackup($backup: Upload!) {
|
||||||
|
restoreBackup(input: { backup: $backup }) {
|
||||||
|
id
|
||||||
|
status { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_MANGA_META = `
|
||||||
|
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||||
|
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_MANGA_META = `
|
||||||
|
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
|
||||||
|
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Mutations
|
||||||
|
|
||||||
|
## Manga (`mutations/manga.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
|
||||||
|
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
|
||||||
|
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
|
||||||
|
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
|
||||||
|
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
|
||||||
|
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
|
||||||
|
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
|
||||||
|
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
|
||||||
|
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
|
||||||
|
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
|
||||||
|
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
|
||||||
|
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|
||||||
|
| `UPDATE_STOP` | — | Stop the currently running library update job |
|
||||||
|
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
|
||||||
|
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
|
||||||
|
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
|
||||||
|
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`mutations/chapters.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
|
||||||
|
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
|
||||||
|
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
|
||||||
|
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
|
||||||
|
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
|
||||||
|
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
|
||||||
|
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
|
||||||
|
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`mutations/downloads.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
|
||||||
|
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
|
||||||
|
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|
||||||
|
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
|
||||||
|
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
|
||||||
|
| `START_DOWNLOADER` | — | Start the downloader |
|
||||||
|
| `STOP_DOWNLOADER` | — | Stop the downloader |
|
||||||
|
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
|
||||||
|
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
|
||||||
|
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
|
||||||
|
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`mutations/extensions.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
|
||||||
|
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
|
||||||
|
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
|
||||||
|
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
|
||||||
|
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
|
||||||
|
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
|
||||||
|
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
|
||||||
|
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
|
||||||
|
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
|
||||||
|
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
|
||||||
|
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
|
||||||
|
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
|
||||||
|
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
|
||||||
|
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
|
||||||
|
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
|
||||||
|
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
|
||||||
|
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
|
||||||
|
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
|
||||||
|
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`mutations/tracking.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
|
||||||
|
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
|
||||||
|
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|
||||||
|
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
|
||||||
|
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
|
||||||
|
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
|
||||||
|
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
|
||||||
|
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
|
||||||
|
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
|
||||||
|
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
|
||||||
|
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
|
||||||
|
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
|
||||||
|
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
|
||||||
|
| `REFRESH_TOKEN` | — | Refresh the current access token |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New in Preview
|
||||||
|
|
||||||
|
Mutations now available and not yet wired to any feature in Moku:
|
||||||
|
|
||||||
|
| Mutation | Potential Feature |
|
||||||
|
|----------|-------------------|
|
||||||
|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
|
||||||
|
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
|
||||||
|
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
|
||||||
|
| `UPDATE_STOP` | Cancel button for library update jobs |
|
||||||
|
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
|
||||||
|
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
|
||||||
|
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
|
||||||
|
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
|
||||||
|
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
|
||||||
|
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
|
||||||
|
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
|
||||||
|
| `RESET_SETTINGS` | Settings page — factory reset button |
|
||||||
|
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
|
||||||
|
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
|
||||||
|
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
|
||||||
|
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const TRACK_RECORD_FRAGMENT = `
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BIND_TRACK = `
|
||||||
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
|
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_TRACK = `
|
||||||
|
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
|
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UNBIND_TRACK = `
|
||||||
|
mutation UnbindTrack($recordId: Int!) {
|
||||||
|
unbindTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_TRACK = `
|
||||||
|
mutation FetchTrack($recordId: Int!) {
|
||||||
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TRACK_PROGRESS = `
|
||||||
|
mutation TrackProgress($mangaId: Int!) {
|
||||||
|
trackProgress(input: { mangaId: $mangaId }) {
|
||||||
|
trackRecords {
|
||||||
|
id trackerId lastChapterRead status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||||
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
|
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_TRACKER = `
|
||||||
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CONNECT_KOSYNC = `
|
||||||
|
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
|
||||||
|
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_KOSYNC = `
|
||||||
|
mutation LogoutKoSync {
|
||||||
|
logoutKoSyncAccount(input: {}) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PULL_KOSYNC_PROGRESS = `
|
||||||
|
mutation PullKoSyncProgress($chapterId: Int!) {
|
||||||
|
pullKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PUSH_KOSYNC_PROGRESS = `
|
||||||
|
mutation PushKoSyncProgress($chapterId: Int!) {
|
||||||
|
pushKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_USER = `
|
||||||
|
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||||
|
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const GET_RECENTLY_UPDATED = `
|
||||||
|
query GetRecentlyUpdated {
|
||||||
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
chapterNumber
|
||||||
|
sourceOrder
|
||||||
|
isRead
|
||||||
|
lastPageRead
|
||||||
|
mangaId
|
||||||
|
fetchedAt
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CHAPTERS = `
|
||||||
|
query GetChapters($mangaId: Int!) {
|
||||||
|
chapters(condition: { mangaId: $mangaId }) {
|
||||||
|
nodes {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
query GetDownloadStatus {
|
||||||
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export const GET_LOCAL_MANGA = `
|
||||||
|
query GetLocalManga {
|
||||||
|
mangas(condition: { sourceId: "0" }) {
|
||||||
|
nodes { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_EXTENSIONS = `
|
||||||
|
query GetExtensions {
|
||||||
|
extensions {
|
||||||
|
nodes {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCES = `
|
||||||
|
query GetSources {
|
||||||
|
sources {
|
||||||
|
nodes {
|
||||||
|
id name lang displayName iconUrl isNsfw
|
||||||
|
isConfigurable supportsLatest
|
||||||
|
extension { pkgName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCE_SETTINGS = `
|
||||||
|
query GetSourceSettings($id: LongString!) {
|
||||||
|
source(id: $id) {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
preferences {
|
||||||
|
... on CheckBoxPreference {
|
||||||
|
type: __typename
|
||||||
|
CheckBoxTitle: title
|
||||||
|
CheckBoxSummary: summary
|
||||||
|
CheckBoxDefault: default
|
||||||
|
CheckBoxCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on SwitchPreference {
|
||||||
|
type: __typename
|
||||||
|
SwitchPreferenceTitle: title
|
||||||
|
SwitchPreferenceSummary: summary
|
||||||
|
SwitchPreferenceDefault: default
|
||||||
|
SwitchPreferenceCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on ListPreference {
|
||||||
|
type: __typename
|
||||||
|
ListPreferenceTitle: title
|
||||||
|
ListPreferenceSummary: summary
|
||||||
|
ListPreferenceDefault: default
|
||||||
|
ListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on EditTextPreference {
|
||||||
|
type: __typename
|
||||||
|
EditTextPreferenceTitle: title
|
||||||
|
EditTextPreferenceSummary: summary
|
||||||
|
EditTextPreferenceDefault: default
|
||||||
|
EditTextPreferenceCurrentValue: currentValue
|
||||||
|
dialogTitle
|
||||||
|
dialogMessage
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on MultiSelectListPreference {
|
||||||
|
type: __typename
|
||||||
|
MultiSelectListPreferenceTitle: title
|
||||||
|
MultiSelectListPreferenceSummary: summary
|
||||||
|
MultiSelectListPreferenceDefault: default
|
||||||
|
MultiSelectListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MIGRATABLE_SOURCES = `
|
||||||
|
query GetMigratableSources {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
sourceId
|
||||||
|
source {
|
||||||
|
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SETTINGS = `
|
||||||
|
query GetSettings {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SERVER_SECURITY = `
|
||||||
|
query GetServerSecurity {
|
||||||
|
settings {
|
||||||
|
authMode authUsername
|
||||||
|
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
||||||
|
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||||
|
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
|
export * from "./updater";
|
||||||
|
export * from "./meta";
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
export const GET_LIBRARY = `
|
||||||
|
query GetLibrary {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||||
|
description status author artist genre
|
||||||
|
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
|
||||||
|
source { id name displayName }
|
||||||
|
chapters { totalCount }
|
||||||
|
latestFetchedChapter { id uploadDate }
|
||||||
|
latestUploadedChapter { id uploadDate }
|
||||||
|
lastReadChapter { id chapterNumber }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_MANGA = `
|
||||||
|
query GetAllManga {
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA = `
|
||||||
|
query GetManga($id: Int!) {
|
||||||
|
manga(id: $id) {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
||||||
|
source { id name displayName }
|
||||||
|
lastReadChapter { id chapterNumber lastPageRead }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
|
highestNumberedChapter { id chapterNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id name order default includeInUpdate includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||||
|
query GetDownloadedChaptersPages {
|
||||||
|
chapters(condition: { isDownloaded: true }) {
|
||||||
|
nodes { pageCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADS_PATH = `
|
||||||
|
query GetDownloadsPath {
|
||||||
|
settings { downloadsPath localSourcePath }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
|
query LibraryUpdateStatus {
|
||||||
|
libraryUpdateStatus {
|
||||||
|
jobsInfo {
|
||||||
|
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||||
|
}
|
||||||
|
mangaUpdates {
|
||||||
|
status
|
||||||
|
manga { id title thumbnailUrl unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastUpdateTimestamp {
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_RESTORE_STATUS = `
|
||||||
|
query GetRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name }
|
||||||
|
missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MANGAS_BY_GENRE = `
|
||||||
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
|
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const GET_META = `
|
||||||
|
query GetMeta($key: String!) {
|
||||||
|
meta(key: $key) {
|
||||||
|
key value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_METAS = `
|
||||||
|
query GetMetas {
|
||||||
|
metas {
|
||||||
|
nodes { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Queries
|
||||||
|
|
||||||
|
## Manga (`queries/manga.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
|
||||||
|
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
|
||||||
|
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
|
||||||
|
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
||||||
|
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||||
|
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||||
|
| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
|
||||||
|
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
||||||
|
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
||||||
|
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`queries/chapters.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
|
||||||
|
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`queries/downloads.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`queries/extensions.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
|
||||||
|
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
|
||||||
|
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
|
||||||
|
| `GET_SETTINGS` | — | `extensionRepos` from settings |
|
||||||
|
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`queries/tracking.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
|
||||||
|
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
|
||||||
|
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
|
||||||
|
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
|
||||||
|
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updater (`queries/updater.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
|
||||||
|
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meta (`queries/meta.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
||||||
|
| `GET_METAS` | — | All global meta entries as a node list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KoSync (`queries/kosync.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New in Preview
|
||||||
|
|
||||||
|
Queries and fields now available but not yet wired to any feature in Moku:
|
||||||
|
|
||||||
|
| Query / Field | Potential Feature |
|
||||||
|
|---------------|-------------------|
|
||||||
|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
|
||||||
|
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
|
||||||
|
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
|
||||||
|
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
|
||||||
|
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
|
||||||
|
| `category` (single by id) | Direct category detail without fetching all categories |
|
||||||
|
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
|
||||||
|
| `source` (single by id) | Source detail page — preferences, filters, browse |
|
||||||
|
| `tracker` (single by id) | Individual tracker detail — statuses, records |
|
||||||
|
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
|
||||||
|
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
|
||||||
|
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
|
||||||
|
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
|
||||||
|
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
|
||||||
|
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
|
||||||
|
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
|
||||||
|
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
|
||||||
|
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
|
||||||
|
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export const GET_TRACKERS = `
|
||||||
|
query GetTrackers {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn isTokenExpired authUrl
|
||||||
|
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||||
|
scores
|
||||||
|
statuses { value name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
|
manga(id: $mangaId) {
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SEARCH_TRACKER = `
|
||||||
|
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||||
|
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||||
|
trackSearches {
|
||||||
|
id trackerId remoteId title coverUrl summary
|
||||||
|
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_TRACKER_RECORDS = `
|
||||||
|
query GetAllTrackerRecords {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn isTokenExpired scores
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId title status displayScore lastChapterRead
|
||||||
|
totalChapters remoteUrl private libraryId
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_TRACKER_RECORDS = `
|
||||||
|
query GetTrackerRecords($trackerId: Int!) {
|
||||||
|
trackers(condition: { id: $trackerId }) {
|
||||||
|
nodes {
|
||||||
|
id name
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id title status displayScore lastChapterRead totalChapters remoteUrl
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const GET_ABOUT_SERVER = `
|
||||||
|
query GetAboutServer {
|
||||||
|
aboutServer {
|
||||||
|
name version buildType buildTime github discord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ABOUT_WEBUI = `
|
||||||
|
query GetAboutWebUI {
|
||||||
|
aboutWebUI {
|
||||||
|
channel tag updateTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CHECK_FOR_SERVER_UPDATES = `
|
||||||
|
query CheckForServerUpdates {
|
||||||
|
checkForServerUpdates {
|
||||||
|
channel tag url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./selectPortal";
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
|
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||||
|
return (menuEl: HTMLElement) => {
|
||||||
|
function position() {
|
||||||
|
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||||
|
const r = triggerEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
const top = r.bottom / zoom + 4;
|
||||||
|
const right = r.right / zoom;
|
||||||
|
const width = menuEl.offsetWidth;
|
||||||
|
const left = Math.max(8, right - width);
|
||||||
|
|
||||||
|
menuEl.style.position = "fixed";
|
||||||
|
menuEl.style.top = `${top}px`;
|
||||||
|
menuEl.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuEl.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(menuEl);
|
||||||
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
position();
|
||||||
|
menuEl.style.visibility = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("scroll", position, true);
|
||||||
|
window.addEventListener("resize", position);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", position, true);
|
||||||
|
window.removeEventListener("resize", position);
|
||||||
|
triggerEl.__selectMenuEl = null;
|
||||||
|
menuEl.remove();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
|
||||||
|
|
||||||
|
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||||
|
return (item) => predicates.every((p) => p(item));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './sort';
|
||||||
|
export * from './filter';
|
||||||
|
export * from './paginate';
|
||||||
|
export * from './search';
|
||||||
|
export * from './queue';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface PaginationState {
|
||||||
|
visible: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationResult<T> {
|
||||||
|
items: T[];
|
||||||
|
hasMore: boolean;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaginator<T>(pageSize: number) {
|
||||||
|
return {
|
||||||
|
slice(all: T[], visible: number): PaginationResult<T> {
|
||||||
|
return {
|
||||||
|
items: all.slice(0, visible),
|
||||||
|
hasMore: all.length > visible,
|
||||||
|
remaining: Math.max(0, all.length - visible),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
nextVisible(current: number): number {
|
||||||
|
return current + pageSize;
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(): number {
|
||||||
|
return pageSize;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface AsyncQueue<T> {
|
||||||
|
enqueue(item: T): void;
|
||||||
|
drain(): void;
|
||||||
|
clear(): void;
|
||||||
|
size(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAsyncQueue<T>(
|
||||||
|
worker: (item: T) => Promise<void>,
|
||||||
|
concurrency = 1,
|
||||||
|
): AsyncQueue<T> {
|
||||||
|
const queue: T[] = [];
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
while (active < concurrency && queue.length > 0) {
|
||||||
|
const item = queue.shift()!;
|
||||||
|
active++;
|
||||||
|
worker(item).finally(() => { active--; next(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue(item) { queue.push(item); next(); },
|
||||||
|
drain() { next(); },
|
||||||
|
clear() { queue.length = 0; },
|
||||||
|
size() { return queue.length; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface SearchResult<T> {
|
||||||
|
item: T;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchItems<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getField: (item: T) => string,
|
||||||
|
): T[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter(item => getField(item).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchWithScore<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getField: (item: T) => string,
|
||||||
|
): SearchResult<T>[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return items.map(item => ({ item, score: 0 }));
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map(item => {
|
||||||
|
const field = getField(item).toLowerCase();
|
||||||
|
if (!field.includes(q)) return null;
|
||||||
|
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
||||||
|
return { item, score };
|
||||||
|
})
|
||||||
|
.filter((r): r is SearchResult<T> => r !== null)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface SortField<T> {
|
||||||
|
key: string;
|
||||||
|
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortConfig<T> {
|
||||||
|
fields: SortField<T>[];
|
||||||
|
defaultField: string;
|
||||||
|
defaultDir: SortDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sorter<T> {
|
||||||
|
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
|
||||||
|
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sort(items, field, dir, context) {
|
||||||
|
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
|
||||||
|
if (!f) return [...items];
|
||||||
|
const d = dir ?? config.defaultDir;
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const cmp = f.comparator(a, b, context);
|
||||||
|
return d === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Runs an async task over every item in `items`, with at most `concurrency`
|
||||||
|
* tasks in-flight at once. Respects the provided AbortSignal — each worker
|
||||||
|
* exits early if the signal fires. Errors thrown by individual tasks are
|
||||||
|
* swallowed so one failure does not cancel the whole batch.
|
||||||
|
*/
|
||||||
|
export async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
concurrency = 6,
|
||||||
|
): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const item = items[i++];
|
||||||
|
await fn(item).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates in-flight async calls by key.
|
||||||
|
*
|
||||||
|
* Two call signatures are supported:
|
||||||
|
*
|
||||||
|
* 1. Direct call — supply a key and a zero-arg factory each time:
|
||||||
|
* dedupeRequest("my-key", () => fetchSomething())
|
||||||
|
* If a request with that key is already pending, the existing Promise is
|
||||||
|
* returned and the factory is not called again.
|
||||||
|
*
|
||||||
|
* 2. Curried wrapper — supply a key-based fetcher once, get back a
|
||||||
|
* single-arg function you can call repeatedly:
|
||||||
|
* const get = dedupeRequest((key) => fetchSomething(key))
|
||||||
|
* get("my-key")
|
||||||
|
*/
|
||||||
|
const _inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
||||||
|
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
||||||
|
export function dedupeRequest<T>(
|
||||||
|
keyOrFn: string | ((key: string) => Promise<T>),
|
||||||
|
factory?: () => Promise<T>,
|
||||||
|
): Promise<T> | ((key: string) => Promise<T>) {
|
||||||
|
// Curried wrapper form
|
||||||
|
if (typeof keyOrFn === 'function') {
|
||||||
|
const fn = keyOrFn;
|
||||||
|
return (key: string) => dedupeRequest(key, () => fn(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct call form
|
||||||
|
const key = keyOrFn;
|
||||||
|
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
||||||
|
const p = factory!().finally(() => _inflight.delete(key));
|
||||||
|
_inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface PaginatedQuery<T> {
|
||||||
|
fetchPage(page: number): Promise<T[]>;
|
||||||
|
reset(): void;
|
||||||
|
hasMore(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedQueryConfig<T> {
|
||||||
|
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaginatedQuery<T>(
|
||||||
|
config: PaginatedQueryConfig<T>,
|
||||||
|
): PaginatedQuery<T> {
|
||||||
|
let _hasMore = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async fetchPage(page) {
|
||||||
|
const { items, hasNextPage } = await config.fetcher(page);
|
||||||
|
_hasMore = hasNextPage;
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
reset() { _hasMore = true; },
|
||||||
|
hasMore() { return _hasMore; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface RetryOptions {
|
||||||
|
maxAttempts?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithRetry<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
baseDelayMs = 500,
|
||||||
|
maxDelayMs = 10_000,
|
||||||
|
shouldRetry = () => true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetcher();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err;
|
||||||
|
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './fetchWithRetry';
|
||||||
|
export * from './batchRequests';
|
||||||
|
export * from './createPaginatedQuery';
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
|
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||||
|
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(msg = "Authentication required") {
|
||||||
|
super(msg);
|
||||||
|
this.name = "AuthRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = "moku_access_token";
|
||||||
|
const UI_SESSION_KEY = "moku_ui_auth_session";
|
||||||
|
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||||
|
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
||||||
|
|
||||||
|
interface StoredAccessToken {
|
||||||
|
base: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredUiAuthSession {
|
||||||
|
base: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientMutationId?: string;
|
||||||
|
accessExpiresAt?: number | null;
|
||||||
|
refreshExpiresAt?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtSettings {
|
||||||
|
jwtAudience?: string | null;
|
||||||
|
jwtRefreshExpiry?: string | null;
|
||||||
|
jwtTokenExpiry?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiAuthDebugStatus {
|
||||||
|
mode: AuthMode;
|
||||||
|
serverBase: string;
|
||||||
|
hasSession: boolean;
|
||||||
|
hasRefreshToken: boolean;
|
||||||
|
accessExpiresAt: number | null;
|
||||||
|
refreshExpiresAt: number | null;
|
||||||
|
accessExpiresInMs: number | null;
|
||||||
|
refreshExpiresInMs: number | null;
|
||||||
|
shouldRefreshSoon: boolean;
|
||||||
|
refreshInFlight: boolean;
|
||||||
|
skewMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _accessToken: string | null = null;
|
||||||
|
let _accessTokenBase: string | null = null;
|
||||||
|
let _uiSession: StoredUiAuthSession | null = null;
|
||||||
|
let _refreshPromise: Promise<string | null> | null = null;
|
||||||
|
let _jwtSettingsBase: string | null = null;
|
||||||
|
let _jwtSettings: JwtSettings | null = null;
|
||||||
|
let _jwtSettingsFetchedAt = 0;
|
||||||
|
|
||||||
|
function authDebug(event: string, fields?: Record<string, unknown>) {
|
||||||
|
if (!AUTH_DEBUG) return;
|
||||||
|
if (fields) {
|
||||||
|
console.debug(`[auth] ${event}`, fields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(`[auth] ${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDuration(duration: string): number | null {
|
||||||
|
try {
|
||||||
|
const match = duration.match(
|
||||||
|
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, years, months, days, hours, minutes, seconds] = match;
|
||||||
|
let ms = 0;
|
||||||
|
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
|
||||||
|
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
|
||||||
|
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
|
||||||
|
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
|
||||||
|
if (minutes) ms += parseInt(minutes) * 60 * 1000;
|
||||||
|
if (seconds) ms += parseFloat(seconds) * 1000;
|
||||||
|
return ms;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpiryMs(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return null;
|
||||||
|
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
||||||
|
const decoded = atob(padded);
|
||||||
|
const json = JSON.parse(decoded) as { exp?: number };
|
||||||
|
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
|
||||||
|
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
|
||||||
|
return Date.now() >= expiresAt - skewMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withExpiryFromSettings(
|
||||||
|
accessToken: string,
|
||||||
|
jwt: JwtSettings | null,
|
||||||
|
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
|
||||||
|
const now = Date.now();
|
||||||
|
const accessExpiresAt =
|
||||||
|
decodeJwtExpiryMs(accessToken)
|
||||||
|
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
|
||||||
|
const refreshExpiresAt =
|
||||||
|
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
|
||||||
|
return { accessExpiresAt, refreshExpiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
|
||||||
|
const res = await fetchAuthenticated(
|
||||||
|
`${base}/api/graphql`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: gqlBody(
|
||||||
|
`query GetJWTSettings {
|
||||||
|
settings {
|
||||||
|
jwtAudience
|
||||||
|
jwtRefreshExpiry
|
||||||
|
jwtTokenExpiry
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
timeoutSignal(5000),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
authDebug("JWT settings fetch failed", { status: res.status });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json?.errors?.length) {
|
||||||
|
authDebug("JWT settings query error", { errors: json.errors });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = json?.data?.settings;
|
||||||
|
if (!settings || typeof settings !== "object") {
|
||||||
|
authDebug("JWT settings missing or invalid", { settings });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
authDebug("JWT settings fetched", {
|
||||||
|
hasAudience: !!settings.jwtAudience,
|
||||||
|
tokenExpiry: settings.jwtTokenExpiry,
|
||||||
|
refreshExpiry: settings.jwtRefreshExpiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
|
||||||
|
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
|
||||||
|
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
||||||
|
const base = getServerBase();
|
||||||
|
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
|
||||||
|
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
|
||||||
|
|
||||||
|
const jwt = await fetchJwtSettings(base);
|
||||||
|
_jwtSettingsBase = base;
|
||||||
|
_jwtSettings = jwt;
|
||||||
|
_jwtSettingsFetchedAt = Date.now();
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiAuth = {
|
||||||
|
getSession: () => {
|
||||||
|
const base = getServerBase();
|
||||||
|
if (_uiSession && _uiSession.base === base) return _uiSession;
|
||||||
|
|
||||||
|
const stored = readStoredSession();
|
||||||
|
if (!stored) return null;
|
||||||
|
if (stored.base !== base) {
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
_uiSession = null;
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiSession = stored;
|
||||||
|
_accessToken = stored.accessToken;
|
||||||
|
_accessTokenBase = stored.base;
|
||||||
|
return _uiSession;
|
||||||
|
},
|
||||||
|
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
|
||||||
|
const base = getServerBase();
|
||||||
|
_uiSession = { ...session, base };
|
||||||
|
_accessToken = session.accessToken;
|
||||||
|
_accessTokenBase = base;
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
getToken: () => {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
if (isExpired(session.accessExpiresAt, 0)) return null;
|
||||||
|
|
||||||
|
const base = getServerBase();
|
||||||
|
if (_accessToken && _accessTokenBase === base) return _accessToken;
|
||||||
|
const stored = readStoredToken();
|
||||||
|
if (!stored) return null;
|
||||||
|
if (stored.base !== base) {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
_uiSession = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_accessToken = stored.token;
|
||||||
|
_accessTokenBase = stored.base;
|
||||||
|
return _accessToken;
|
||||||
|
},
|
||||||
|
setToken: (t: string) => {
|
||||||
|
const existing = uiAuth.getSession();
|
||||||
|
if (existing?.refreshToken) {
|
||||||
|
uiAuth.setSession({
|
||||||
|
...existing,
|
||||||
|
accessToken: t,
|
||||||
|
...withExpiryFromSettings(t, _jwtSettings),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = getServerBase();
|
||||||
|
_accessToken = t;
|
||||||
|
_accessTokenBase = base;
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
|
||||||
|
},
|
||||||
|
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||||
|
uiAuth.setSession({
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
refreshToken: payload.refreshToken,
|
||||||
|
clientMutationId: payload.clientMutationId,
|
||||||
|
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||||
|
const existing = uiAuth.getSession();
|
||||||
|
if (!existing?.refreshToken) {
|
||||||
|
uiAuth.setToken(payload.accessToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uiAuth.setSession({
|
||||||
|
...existing,
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
|
||||||
|
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||||
|
refreshToken: existing.refreshToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearToken: () => {
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
_uiSession = null;
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authSession = {
|
||||||
|
clearTokens() {
|
||||||
|
_refreshPromise = null;
|
||||||
|
_jwtSettings = null;
|
||||||
|
_jwtSettingsBase = null;
|
||||||
|
_jwtSettingsFetchedAt = 0;
|
||||||
|
uiAuth.clearToken();
|
||||||
|
},
|
||||||
|
hasSession(): boolean {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServerBase(): string {
|
||||||
|
const url = store.settings.serverUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredToken(): StoredAccessToken | null {
|
||||||
|
const session = readStoredSession();
|
||||||
|
if (session) return { base: session.base, token: session.accessToken };
|
||||||
|
|
||||||
|
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
|
||||||
|
return { base: parsed.base, token: parsed.token };
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated = { base: getServerBase(), token: raw.trim() };
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSession(): StoredUiAuthSession | null {
|
||||||
|
const raw = sessionStorage.getItem(UI_SESSION_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
|
||||||
|
return {
|
||||||
|
base: parsed.base,
|
||||||
|
accessToken: parsed.accessToken,
|
||||||
|
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
|
||||||
|
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
|
||||||
|
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
|
||||||
|
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!legacy?.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(legacy);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
|
||||||
|
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutSignal(ms: number): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||||
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerHeader(token: string): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthenticated(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
skipped = false,
|
||||||
|
): Promise<Response> {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||||
|
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return fetch(url, {
|
||||||
|
...init, signal, credentials: "omit",
|
||||||
|
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "UI_LOGIN") {
|
||||||
|
const token = await getUIAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(url, {
|
||||||
|
...init, signal, credentials: "omit",
|
||||||
|
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 401 || skipped) return res;
|
||||||
|
|
||||||
|
const refreshed = await refreshUiAccessToken(true);
|
||||||
|
if (!refreshed) return res;
|
||||||
|
|
||||||
|
res = await fetch(url, {
|
||||||
|
...init, signal, credentials: "omit",
|
||||||
|
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
if (forceRefresh || isExpired(session.accessExpiresAt)) {
|
||||||
|
return refreshUiAccessToken(true);
|
||||||
|
}
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
if (!session.refreshToken) {
|
||||||
|
if (force && isExpired(session.accessExpiresAt, 0)) return null;
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
|
||||||
|
if (isExpired(session.refreshExpiresAt)) {
|
||||||
|
authDebug("refresh skipped: refresh token expired", {
|
||||||
|
force,
|
||||||
|
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_refreshPromise) {
|
||||||
|
authDebug("refresh joined existing request");
|
||||||
|
return _refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
authDebug("refresh start", {
|
||||||
|
force,
|
||||||
|
accessExpiresAt: session.accessExpiresAt ?? null,
|
||||||
|
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
_refreshPromise = (async () => {
|
||||||
|
const base = getServerBase();
|
||||||
|
const jwt = await getJwtSettings().catch(() => null);
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "omit",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: gqlBody(
|
||||||
|
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
authDebug("refresh rejected by server", { status: res.status });
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
authDebug("refresh failed with HTTP error", { status: res.status });
|
||||||
|
throw new Error(`Token refresh failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const refreshed = json?.data?.refreshToken;
|
||||||
|
const nextAccessToken: string | undefined = refreshed?.accessToken;
|
||||||
|
if (!nextAccessToken) {
|
||||||
|
const msg = json?.errors?.[0]?.message;
|
||||||
|
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
|
||||||
|
authDebug("refresh rejected by GraphQL error", { message: msg });
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
authDebug("refresh returned no access token", { message: msg ?? null });
|
||||||
|
throw new Error(msg ?? "Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
uiAuth.updateAccessToken(
|
||||||
|
{
|
||||||
|
accessToken: nextAccessToken,
|
||||||
|
clientMutationId: typeof refreshed?.clientMutationId === "string"
|
||||||
|
? refreshed.clientMutationId
|
||||||
|
: session.clientMutationId,
|
||||||
|
},
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
authDebug("refresh success", {
|
||||||
|
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
return nextAccessToken;
|
||||||
|
})()
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
authDebug("refresh threw error", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return _refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
const accessExpiresAt = session?.accessExpiresAt ?? null;
|
||||||
|
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
|
||||||
|
serverBase: getServerBase(),
|
||||||
|
hasSession: !!session,
|
||||||
|
hasRefreshToken: !!session?.refreshToken,
|
||||||
|
accessExpiresAt,
|
||||||
|
refreshExpiresAt,
|
||||||
|
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
|
||||||
|
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
|
||||||
|
shouldRefreshSoon: isExpired(accessExpiresAt),
|
||||||
|
refreshInFlight: _refreshPromise !== null,
|
||||||
|
skewMs: TOKEN_REFRESH_SKEW_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
|
method: "POST", credentials: "omit",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: gqlBody(
|
||||||
|
`mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ username: user, password: pass },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(8000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||||
|
const json = await res.json();
|
||||||
|
const payload = json?.data?.login;
|
||||||
|
const accessToken: string | undefined = payload?.accessToken;
|
||||||
|
const refreshToken: string | undefined = payload?.refreshToken;
|
||||||
|
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||||
|
|
||||||
|
authDebug("login success", { user });
|
||||||
|
|
||||||
|
const preliminarySession = {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
uiAuth.setLoginSession(preliminarySession, null);
|
||||||
|
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
|
||||||
|
|
||||||
|
const jwt = await getJwtSettings(true).catch(() => null);
|
||||||
|
uiAuth.setLoginSession(preliminarySession, jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
|
method: "POST", credentials: "omit",
|
||||||
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
|
body: gqlBody("{ __typename }"),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
uiAuth.clearToken();
|
||||||
|
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||||
|
const base = getServerBase();
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
const s = store.settings;
|
||||||
|
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
||||||
|
|
||||||
|
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
|
} else if (mode === "UI_LOGIN" && token) {
|
||||||
|
Object.assign(headers, bearerHeader(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST", credentials: "omit", headers,
|
||||||
|
body: gqlBody("{ __typename }"),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) return "ok";
|
||||||
|
if (res.status === 401) return "auth_required";
|
||||||
|
return "unreachable";
|
||||||
|
} catch {
|
||||||
|
return "unreachable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
persistSettings,
|
||||||
|
persistLibrary,
|
||||||
|
persistUpdates,
|
||||||
|
} from "@core/persistence/persist";
|
||||||
|
|
||||||
|
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
|
||||||
|
|
||||||
|
export async function exportAppData(): Promise<void> {
|
||||||
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await invoke("export_app_data", { bytes: Array.from(zip) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAppData(): Promise<void> {
|
||||||
|
const raw: number[] = await invoke("import_app_data");
|
||||||
|
const files = parseZip(new Uint8Array(raw));
|
||||||
|
|
||||||
|
const decode = (name: string) => {
|
||||||
|
const bytes = files.get(name);
|
||||||
|
if (!bytes) throw new Error(`Backup is missing ${name}`);
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = decode("settings.json");
|
||||||
|
const l = decode("library.json");
|
||||||
|
const u = decode("updates.json");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
persistSettings({
|
||||||
|
settings: s.settings ?? null,
|
||||||
|
storeVersion: s.storeVersion ?? 1,
|
||||||
|
}),
|
||||||
|
persistLibrary({
|
||||||
|
history: l.history ?? [],
|
||||||
|
bookmarks: l.bookmarks ?? [],
|
||||||
|
markers: l.markers ?? [],
|
||||||
|
readLog: l.readLog ?? [],
|
||||||
|
readingStats: l.readingStats ?? null,
|
||||||
|
dailyReadCounts: l.dailyReadCounts ?? {},
|
||||||
|
}),
|
||||||
|
persistUpdates({
|
||||||
|
libraryUpdates: u.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await showExitModal();
|
||||||
|
invoke("exit_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExitModal(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "s-backdrop";
|
||||||
|
backdrop.style.cssText = "z-index:99999";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.style.cssText = [
|
||||||
|
"background:var(--bg-surface)",
|
||||||
|
"border:1px solid var(--border-base)",
|
||||||
|
"border-radius:var(--radius-2xl)",
|
||||||
|
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
"width:min(400px,calc(100vw - 40px))",
|
||||||
|
"display:flex",
|
||||||
|
"flex-direction:column",
|
||||||
|
"overflow:hidden",
|
||||||
|
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
|
||||||
|
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
|
||||||
|
title.textContent = "Import complete";
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
|
||||||
|
|
||||||
|
const sub = document.createElement("p");
|
||||||
|
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
|
||||||
|
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
|
||||||
|
|
||||||
|
const counter = document.createElement("p");
|
||||||
|
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
|
||||||
|
counter.textContent = "Closing in 3…";
|
||||||
|
|
||||||
|
body.append(sub, counter);
|
||||||
|
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "s-btn s-btn-danger";
|
||||||
|
btn.textContent = "Close now";
|
||||||
|
|
||||||
|
footer.appendChild(btn);
|
||||||
|
modal.append(header, body, footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
let secs = 3;
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
secs--;
|
||||||
|
counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…";
|
||||||
|
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[moku] auto-backup failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(data: Uint8Array): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of data) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(30 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x04034b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint32(14, crc32(data), true);
|
||||||
|
v.setUint32(18, data.byteLength, true);
|
||||||
|
v.setUint32(22, data.byteLength, true);
|
||||||
|
v.setUint16(26, name.byteLength, true);
|
||||||
|
v.setUint16(28, 0, true);
|
||||||
|
new Uint8Array(buf).set(name, 30);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(46 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x02014b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 20, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint16(14, 0, true);
|
||||||
|
v.setUint32(16, crc32(data), true);
|
||||||
|
v.setUint32(20, data.byteLength, true);
|
||||||
|
v.setUint32(24, data.byteLength, true);
|
||||||
|
v.setUint16(28, name.byteLength, true);
|
||||||
|
v.setUint16(30, 0, true);
|
||||||
|
v.setUint16(32, 0, true);
|
||||||
|
v.setUint16(34, 0, true);
|
||||||
|
v.setUint16(36, 0, true);
|
||||||
|
v.setUint32(38, 0, true);
|
||||||
|
v.setUint32(42, offset, true);
|
||||||
|
new Uint8Array(buf).set(name, 46);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(22);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x06054b50, true);
|
||||||
|
v.setUint16(4, 0, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, count, true);
|
||||||
|
v.setUint16(10, count, true);
|
||||||
|
v.setUint32(12, cdSize, true);
|
||||||
|
v.setUint32(16, cdOffset, true);
|
||||||
|
v.setUint16(20, 0, true);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
const offsets: number[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
for (const { name, bytes } of files) {
|
||||||
|
const nameBytes = enc.encode(name);
|
||||||
|
const lh = localHeader(nameBytes, bytes);
|
||||||
|
offsets.push(pos);
|
||||||
|
parts.push(lh, bytes);
|
||||||
|
pos += lh.byteLength + bytes.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdParts = files.map(({ name, bytes }, i) =>
|
||||||
|
centralHeader(enc.encode(name), bytes, offsets[i])
|
||||||
|
);
|
||||||
|
const cd = concat(cdParts);
|
||||||
|
|
||||||
|
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
|
||||||
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
|
||||||
|
const fnLen = view.getUint16(pos + 26, true);
|
||||||
|
const exLen = view.getUint16(pos + 28, true);
|
||||||
|
const cSize = view.getUint32(pos + 18, true);
|
||||||
|
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
|
||||||
|
const start = pos + 30 + fnLen + exLen;
|
||||||
|
files.set(name, data.subarray(start, start + cSize));
|
||||||
|
pos = start + cSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let pos = 0;
|
||||||
|
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Vendored
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { getUIAccessToken } from "@core/auth";
|
||||||
|
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
const inflight = new Map<string, Promise<string>>();
|
||||||
|
const MAX_CONCURRENT = 6;
|
||||||
|
let active = 0;
|
||||||
|
let drainScheduled = false;
|
||||||
|
let clearing = false;
|
||||||
|
|
||||||
|
interface QueueEntry {
|
||||||
|
url: string;
|
||||||
|
priority: number;
|
||||||
|
resolve: (v: string) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "UI_LOGIN") {
|
||||||
|
const token = await getUIAccessToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(url: string): Promise<string> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await tauriFetch(url, { method: "GET", headers });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
cache.set(url, blobUrl);
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSorted(entry: QueueEntry) {
|
||||||
|
let lo = 0, hi = queue.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (queue[mid].priority > entry.priority) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
queue.splice(lo, 0, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
drainScheduled = false;
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
const entry = queue.shift()!;
|
||||||
|
active++;
|
||||||
|
doFetch(entry.url)
|
||||||
|
.then(entry.resolve, entry.reject)
|
||||||
|
.finally(() => { active--; drain(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDrain() {
|
||||||
|
if (drainScheduled) return;
|
||||||
|
drainScheduled = true;
|
||||||
|
requestAnimationFrame(drain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(url: string, priority: number): Promise<string> {
|
||||||
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
|
insertSorted({ url, priority, resolve, reject });
|
||||||
|
}).catch(err => {
|
||||||
|
inflight.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
inflight.set(url, promise);
|
||||||
|
scheduleDrain();
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||||
|
if (!url) return Promise.resolve("");
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
const existing = inflight.get(url);
|
||||||
|
if (existing) {
|
||||||
|
const idx = queue.findIndex(e => e.url === url);
|
||||||
|
if (idx !== -1 && priority > queue[idx].priority) {
|
||||||
|
const [entry] = queue.splice(idx, 1);
|
||||||
|
entry.priority = priority;
|
||||||
|
insertSorted(entry);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return enqueue(url, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
||||||
|
urls.forEach((url, i) => {
|
||||||
|
if (!url || cache.has(url) || inflight.has(url)) return;
|
||||||
|
enqueue(url, basePriority - i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeBlobUrl(url: string): void {
|
||||||
|
const blob = cache.get(url);
|
||||||
|
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deprioritizeQueue(): void {
|
||||||
|
for (const entry of queue) entry.priority = 0;
|
||||||
|
queue.sort((a, b) => b.priority - a.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelQueuedFetches(): void {
|
||||||
|
const dropped = queue.splice(0);
|
||||||
|
for (const entry of dropped) {
|
||||||
|
inflight.delete(entry.url);
|
||||||
|
entry.reject(new DOMException("Cancelled", "AbortError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBlobCache(): void {
|
||||||
|
clearing = true;
|
||||||
|
cancelQueuedFetches();
|
||||||
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
|
cache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
clearing = false;
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './memoryCache';
|
||||||
|
export * from './pageCache';
|
||||||
|
export * from './imageCache';
|
||||||
|
export * from './queryCache';
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
interface MemEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryCache<T> {
|
||||||
|
readonly #cap: number;
|
||||||
|
readonly #ttl: number;
|
||||||
|
readonly #map = new Map<string, MemEntry<T>>();
|
||||||
|
|
||||||
|
constructor(capacity: number, ttlMs: number) {
|
||||||
|
this.#cap = capacity;
|
||||||
|
this.#ttl = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | undefined {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||||
|
this.#map.delete(key);
|
||||||
|
this.#map.set(key, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: T): void {
|
||||||
|
if (this.#map.has(key)) this.#map.delete(key);
|
||||||
|
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||||
|
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string): boolean {
|
||||||
|
const entry = this.#map.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void { this.#map.delete(key); }
|
||||||
|
|
||||||
|
clear(): void { this.#map.clear(); }
|
||||||
|
|
||||||
|
get size(): number { return this.#map.size; }
|
||||||
|
}
|
||||||
Vendored
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { gql, getServerUrl } from "@api/client";
|
||||||
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
|
|
||||||
|
const pageCache = new Map<number, string[]>();
|
||||||
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
|
if (!useBlob) return Promise.resolve(url);
|
||||||
|
const cached = resolvedUrlCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
const p = getBlobUrl(url, priority).catch(err => {
|
||||||
|
resolvedUrlCache.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
resolvedUrlCache.set(url, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPages(
|
||||||
|
chapterId: number,
|
||||||
|
useBlob: boolean,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
priorityPage = 0,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cached = pageCache.get(chapterId);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
|
||||||
|
if (!inflight.has(chapterId)) {
|
||||||
|
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||||
|
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||||
|
.then(d => {
|
||||||
|
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
|
||||||
|
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||||
|
pageCache.set(chapterId, urls);
|
||||||
|
return urls;
|
||||||
|
})
|
||||||
|
).finally(() => inflight.delete(chapterId));
|
||||||
|
inflight.set(chapterId, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = inflight.get(chapterId)!;
|
||||||
|
if (!signal) return base;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
|
base.then(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||||
|
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||||
|
return resolveUrl(url, useBlob).then(src => new Promise(res => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||||||
|
img.onerror = () => res(0.67);
|
||||||
|
img.src = src;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
|
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||||
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPageCache(chapterId?: number): void {
|
||||||
|
if (chapterId !== undefined) {
|
||||||
|
pageCache.delete(chapterId);
|
||||||
|
inflight.delete(chapterId);
|
||||||
|
} else {
|
||||||
|
pageCache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+241
@@ -0,0 +1,241 @@
|
|||||||
|
interface Entry<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
fetchedAt: number;
|
||||||
|
fetcher?: () => Promise<T>;
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Entry<unknown>>();
|
||||||
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
const keyToGroups = new Map<string, Set<string>>();
|
||||||
|
const groups = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
|
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
|
||||||
|
|
||||||
|
function registerGroups(key: string, group?: string | string[]) {
|
||||||
|
if (!group) return;
|
||||||
|
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||||
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
|
groups.get(tag)!.add(key);
|
||||||
|
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||||
|
keyToGroups.get(key)!.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterKey(key: string) {
|
||||||
|
const tags = keyToGroups.get(key);
|
||||||
|
if (tags) {
|
||||||
|
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||||
|
keyToGroups.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = {
|
||||||
|
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
||||||
|
const promise = fetcher().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}) as Promise<T>;
|
||||||
|
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||||
|
registerGroups(key, group);
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
set<T>(key: string, value: T, group?: string | string[]) {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
store.set(key, {
|
||||||
|
promise: Promise.resolve(value),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
fetcher: existing?.fetcher,
|
||||||
|
ttl: existing?.ttl,
|
||||||
|
});
|
||||||
|
registerGroups(key, group);
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
update<T>(key: string, fn: (prev: T) => T) {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing) return;
|
||||||
|
const next = existing.promise.then(fn);
|
||||||
|
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||||
|
next.then(() => notify(key)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh<T>(key: string): Promise<T> | undefined {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing?.fetcher) return undefined;
|
||||||
|
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshGroup(tag: string): void {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of [...keys]) {
|
||||||
|
const existing = store.get(key);
|
||||||
|
if (existing?.fetcher) {
|
||||||
|
const promise = existing.fetcher().catch(err => {
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
|
ageOf(key: string): number | undefined {
|
||||||
|
const e = store.get(key);
|
||||||
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
isStale(key: string): boolean {
|
||||||
|
const e = store.get(key);
|
||||||
|
if (!e) return true;
|
||||||
|
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(key: string) {
|
||||||
|
unregisterKey(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearGroup(tag: string) {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of [...keys]) {
|
||||||
|
keyToGroups.get(key)?.delete(tag);
|
||||||
|
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
}
|
||||||
|
groups.delete(tag);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAll() {
|
||||||
|
const allKeys = [...store.keys()];
|
||||||
|
store.clear();
|
||||||
|
groups.clear();
|
||||||
|
keyToGroups.clear();
|
||||||
|
allKeys.forEach(notify);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
|
subs.get(key)!.add(cb);
|
||||||
|
return () => subs.get(key)?.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CACHE_GROUPS = {
|
||||||
|
LIBRARY: "g:library",
|
||||||
|
SOURCES: "g:sources",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CACHE_KEYS = {
|
||||||
|
LIBRARY: "library",
|
||||||
|
RECENT_UPDATES: "recent_updates",
|
||||||
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
|
CATEGORIES: "categories",
|
||||||
|
SEARCH: "search_all_manga",
|
||||||
|
SOURCES: "sources",
|
||||||
|
POPULAR: "popular",
|
||||||
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
|
||||||
|
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `pages:${sourceId}:${type}:${q}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `page:${sourceId}:${type}:${page}:${q}`;
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||||
|
const p = fetcher().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _pageSets = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
export interface PageSet {
|
||||||
|
add(page: number): void;
|
||||||
|
pages(): Set<number>;
|
||||||
|
next(): number;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
|
||||||
|
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
||||||
|
return {
|
||||||
|
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
|
||||||
|
pages() { return new Set(_pageSets.get(key) ?? []); },
|
||||||
|
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
||||||
|
clear() { _pageSets.delete(key); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRECENCY_KEY = "moku-source-frecency";
|
||||||
|
const MAX_FRECENCY_SOURCES = 4;
|
||||||
|
type FrecencyMap = Record<string, number>;
|
||||||
|
|
||||||
|
function loadFrecency(): FrecencyMap {
|
||||||
|
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFrecency(map: FrecencyMap) {
|
||||||
|
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSourceAccess(sourceId: string) {
|
||||||
|
if (!sourceId || sourceId === "0") return;
|
||||||
|
const map = loadFrecency();
|
||||||
|
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
||||||
|
saveFrecency(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||||
|
const map = loadFrecency();
|
||||||
|
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
|
||||||
|
if (withScore.some(x => x.score > 0)) {
|
||||||
|
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
|
||||||
|
}
|
||||||
|
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||||
|
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
|
||||||
|
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||||
|
|
||||||
|
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||||
|
revokeBlobUrl(thumbnailUrl);
|
||||||
|
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { store, linkManga } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
|
||||||
|
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||||
|
{ type: "module" },
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||||
|
const matches = e.data;
|
||||||
|
for (const id of matches) linkManga(focal.id, id);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(matches.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = () => { worker.terminate(); resolve(0); };
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
focalTitle: focal.title,
|
||||||
|
focalId: focal.id,
|
||||||
|
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
||||||
|
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface WorkerMsg {
|
||||||
|
focalTitle: string;
|
||||||
|
focalId: number;
|
||||||
|
allManga: { id: number; title: string }[];
|
||||||
|
linkedIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wa = new Set(norm(a));
|
||||||
|
const wb = new Set(norm(b));
|
||||||
|
if (!wa.size || !wb.size) return 0;
|
||||||
|
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||||
|
return intersection / new Set([...wa, ...wb]).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||||
|
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||||
|
const matches: number[] = [];
|
||||||
|
|
||||||
|
for (const m of allManga) {
|
||||||
|
if (m.id === focalId) continue;
|
||||||
|
if (linkedIds.includes(m.id)) continue;
|
||||||
|
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage(matches);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const THUMB_SIZE = 16;
|
||||||
|
const DUPE_THRESH = 0.12;
|
||||||
|
|
||||||
|
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||||
|
|
||||||
|
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
||||||
|
const gray = new Uint8ClampedArray(pixels);
|
||||||
|
for (let i = 0; i < pixels; i++) {
|
||||||
|
const o = i * 4;
|
||||||
|
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||||
|
}
|
||||||
|
return gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvas.height = THUMB_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||||
|
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||||
|
return diff / (a.length * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
||||||
|
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||||
|
try {
|
||||||
|
const thumb = await loadThumb(url);
|
||||||
|
hashCache.set(url, thumb);
|
||||||
|
return thumb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||||
|
return similarity(a, b) <= DUPE_THRESH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHashCache(): void {
|
||||||
|
hashCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { searchWithScore } from "@core/algorithms/search";
|
||||||
|
import { getHash, areDuplicates } from "@core/cover/coverHash";
|
||||||
|
|
||||||
|
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
||||||
|
|
||||||
|
export type CoverCandidate = {
|
||||||
|
mangaId: number;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FUZZY_SCORE_THRESHOLD = 0.65;
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.search = "";
|
||||||
|
return u.href.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
||||||
|
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzyMatchIds(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): number[] {
|
||||||
|
const results = searchWithScore(
|
||||||
|
[...mangaById.values()].filter(m => m.id !== mangaId),
|
||||||
|
title,
|
||||||
|
m => m.title,
|
||||||
|
);
|
||||||
|
return results
|
||||||
|
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
||||||
|
.map(r => r.item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverCandidatesSync(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
ownUrl: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): CoverCandidate[] {
|
||||||
|
const linkedIds = store.getLinkedMangaIds(mangaId);
|
||||||
|
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
||||||
|
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
|
||||||
|
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
||||||
|
|
||||||
|
const raw: { mangaId: number; url: string; label: string }[] = [
|
||||||
|
{ mangaId, url: ownUrl, label: "This source" },
|
||||||
|
...allIds.flatMap(id => {
|
||||||
|
const m = mangaById.get(id);
|
||||||
|
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return raw
|
||||||
|
.filter(c => {
|
||||||
|
const key = normalizeUrl(c.url);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
||||||
|
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
||||||
|
|
||||||
|
const groups: number[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const hi = hashes[i];
|
||||||
|
const existing = hi
|
||||||
|
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
|
||||||
|
: undefined;
|
||||||
|
if (existing) existing.push(i);
|
||||||
|
else groups.push([i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map(group => {
|
||||||
|
const active = group.find(i => candidates[i].isActive) ?? group[0];
|
||||||
|
const labels = [...new Set(group.map(i => candidates[i].label))];
|
||||||
|
return { ...candidates[active], label: labels.join(" · ") };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
||||||
|
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
||||||
|
export type { CoverCandidate } from "./coverResolver";
|
||||||
|
export { autoLinkLibrary } from "./autoLink";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface Keybinds {
|
||||||
|
turnPageRight: string;
|
||||||
|
turnPageLeft: string;
|
||||||
|
firstPage: string;
|
||||||
|
lastPage: string;
|
||||||
|
turnChapterRight: string;
|
||||||
|
turnChapterLeft: string;
|
||||||
|
exitReader: string;
|
||||||
|
toggleReadingDirection: string;
|
||||||
|
togglePageStyle: string;
|
||||||
|
toggleFullscreen: string;
|
||||||
|
openSettings: string;
|
||||||
|
toggleBookmark: string;
|
||||||
|
toggleMarker: string;
|
||||||
|
toggleAutoScroll: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||||
|
turnPageRight: "ArrowRight",
|
||||||
|
turnPageLeft: "ArrowLeft",
|
||||||
|
firstPage: "ctrl+ArrowLeft",
|
||||||
|
lastPage: "ctrl+ArrowRight",
|
||||||
|
turnChapterRight: "]",
|
||||||
|
turnChapterLeft: "[",
|
||||||
|
exitReader: "Backspace",
|
||||||
|
toggleReadingDirection: "d",
|
||||||
|
togglePageStyle: "q",
|
||||||
|
toggleFullscreen: "f",
|
||||||
|
openSettings: "o",
|
||||||
|
toggleBookmark: "m",
|
||||||
|
toggleMarker: "n",
|
||||||
|
toggleAutoScroll: "s",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||||
|
turnPageRight: "Turn page right (→)",
|
||||||
|
turnPageLeft: "Turn page left (←)",
|
||||||
|
firstPage: "Jump to first page",
|
||||||
|
lastPage: "Jump to last page",
|
||||||
|
turnChapterRight: "Turn chapter right (→)",
|
||||||
|
turnChapterLeft: "Turn chapter left (←)",
|
||||||
|
exitReader: "Exit reader",
|
||||||
|
toggleReadingDirection: "Toggle reading direction",
|
||||||
|
togglePageStyle: "Toggle page style",
|
||||||
|
toggleFullscreen: "Toggle fullscreen",
|
||||||
|
openSettings: "Open settings",
|
||||||
|
toggleBookmark: "Toggle bookmark",
|
||||||
|
toggleMarker: "Toggle marker",
|
||||||
|
toggleAutoScroll: "Toggle auto scroll",
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
||||||
|
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
||||||
|
export type { Keybinds } from "./defaultBinds";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
export function eventToKeybind(e: KeyboardEvent): string {
|
||||||
|
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.ctrlKey) parts.push("ctrl");
|
||||||
|
if (e.altKey) parts.push("alt");
|
||||||
|
if (e.shiftKey) parts.push("shift");
|
||||||
|
if (e.metaKey) parts.push("meta");
|
||||||
|
parts.push(e.key);
|
||||||
|
return parts.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||||
|
return eventToKeybind(e) === bind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFullscreen(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
await win.setFullscreen(!await win.isFullscreen());
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("toggleFullscreen unavailable:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const VAULT_KEY = "moku-credential-vault";
|
||||||
|
const SALT_ITERATIONS = 200_000;
|
||||||
|
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||||
|
|
||||||
|
export interface VaultPayload {
|
||||||
|
refreshToken?: string;
|
||||||
|
basicUser?: string;
|
||||||
|
basicPass?: string;
|
||||||
|
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredVault {
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toB64(buf: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromB64(s: string): Uint8Array {
|
||||||
|
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||||
|
keyMat,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
KEY_USAGE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vaultExists(): boolean {
|
||||||
|
return !!localStorage.getItem(VAULT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const key = await deriveKey(pin, salt);
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const cipher = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
key,
|
||||||
|
enc.encode(JSON.stringify(payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||||
|
salt: toB64(salt),
|
||||||
|
iv: toB64(iv),
|
||||||
|
data: toB64(cipher),
|
||||||
|
} satisfies StoredVault));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||||
|
const raw = localStorage.getItem(VAULT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(raw) as StoredVault;
|
||||||
|
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||||
|
const plain = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||||
|
key,
|
||||||
|
fromB64(stored.data),
|
||||||
|
);
|
||||||
|
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearVault(): void {
|
||||||
|
localStorage.removeItem(VAULT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||||
|
const payload = await unlockVault(oldPin);
|
||||||
|
if (!payload) return false;
|
||||||
|
await lockVault(newPin, payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||||
|
export type { PersistedData } from "./persist";
|
||||||
|
|
||||||
|
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||||
|
export type { VaultPayload } from "./credentialVault";
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { LazyStore } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
||||||
|
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
||||||
|
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
||||||
|
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
||||||
|
|
||||||
|
export interface PersistedData {
|
||||||
|
settings: any;
|
||||||
|
storeVersion: number | null;
|
||||||
|
history: any[];
|
||||||
|
bookmarks: any[];
|
||||||
|
markers: any[];
|
||||||
|
readLog: any[];
|
||||||
|
readingStats: any | null;
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
libraryUpdates: any[];
|
||||||
|
lastLibraryRefresh: number;
|
||||||
|
acknowledgedUpdateIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllStores(): Promise<PersistedData> {
|
||||||
|
const migrated = await migrateFromLocalStorage();
|
||||||
|
if (migrated) return migrated;
|
||||||
|
|
||||||
|
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
|
||||||
|
settingsStore.get<number>("storeVersion"),
|
||||||
|
settingsStore.get<any>("settings"),
|
||||||
|
libraryStore.get<any[]>("history"),
|
||||||
|
libraryStore.get<any[]>("bookmarks"),
|
||||||
|
libraryStore.get<any[]>("markers"),
|
||||||
|
libraryStore.get<any[]>("readLog"),
|
||||||
|
libraryStore.get<any>("readingStats"),
|
||||||
|
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
||||||
|
updatesStore.get<any[]>("libraryUpdates"),
|
||||||
|
updatesStore.get<number>("lastLibraryRefresh"),
|
||||||
|
updatesStore.get<number[]>("acknowledgedUpdateIds"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeVersion: sv ?? null,
|
||||||
|
settings: s ?? null,
|
||||||
|
history: hist ?? [],
|
||||||
|
bookmarks: bk ?? [],
|
||||||
|
markers: mk ?? [],
|
||||||
|
readLog: rl ?? [],
|
||||||
|
readingStats: rs ?? null,
|
||||||
|
dailyReadCounts: dc ?? {},
|
||||||
|
libraryUpdates: lu ?? [],
|
||||||
|
lastLibraryRefresh: llr ?? 0,
|
||||||
|
acknowledgedUpdateIds: au ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku-store");
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
|
||||||
|
persistLibrary({
|
||||||
|
history: data.history ?? [],
|
||||||
|
bookmarks: data.bookmarks ?? [],
|
||||||
|
markers: data.markers ?? [],
|
||||||
|
readLog: data.readLog ?? [],
|
||||||
|
readingStats: data.readingStats ?? null,
|
||||||
|
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||||
|
}),
|
||||||
|
persistUpdates({
|
||||||
|
libraryUpdates: data.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
localStorage.removeItem("moku-store");
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeVersion: data.storeVersion ?? null,
|
||||||
|
settings: data.settings ?? null,
|
||||||
|
history: data.history ?? [],
|
||||||
|
bookmarks: data.bookmarks ?? [],
|
||||||
|
markers: data.markers ?? [],
|
||||||
|
readLog: data.readLog ?? [],
|
||||||
|
readingStats: data.readingStats ?? null,
|
||||||
|
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||||
|
libraryUpdates: data.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistSettings(data: { settings: any; storeVersion: number }) {
|
||||||
|
await Promise.all([
|
||||||
|
settingsStore.set("settings", data.settings),
|
||||||
|
settingsStore.set("storeVersion", data.storeVersion),
|
||||||
|
]);
|
||||||
|
await settingsStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistLibrary(data: {
|
||||||
|
history: any[];
|
||||||
|
bookmarks: any[];
|
||||||
|
markers: any[];
|
||||||
|
readLog: any[];
|
||||||
|
readingStats: any;
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
await Promise.all([
|
||||||
|
libraryStore.set("history", data.history),
|
||||||
|
libraryStore.set("bookmarks", data.bookmarks),
|
||||||
|
libraryStore.set("markers", data.markers),
|
||||||
|
libraryStore.set("readLog", data.readLog),
|
||||||
|
libraryStore.set("readingStats", data.readingStats),
|
||||||
|
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
|
||||||
|
]);
|
||||||
|
await libraryStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistUpdates(data: {
|
||||||
|
libraryUpdates: any[];
|
||||||
|
lastLibraryRefresh: number;
|
||||||
|
acknowledgedUpdateIds: number[];
|
||||||
|
}) {
|
||||||
|
await Promise.all([
|
||||||
|
updatesStore.set("libraryUpdates", data.libraryUpdates),
|
||||||
|
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
|
||||||
|
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
|
||||||
|
]);
|
||||||
|
await updatesStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupEntry { url: string; name: string; }
|
||||||
|
|
||||||
|
export async function loadBackups(): Promise<BackupEntry[]> {
|
||||||
|
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
||||||
|
if (fromStore) return fromStore;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku_backups");
|
||||||
|
if (!raw) return [];
|
||||||
|
const migrated: BackupEntry[] = JSON.parse(raw);
|
||||||
|
await persistBackups(migrated);
|
||||||
|
localStorage.removeItem("moku_backups");
|
||||||
|
return migrated;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||||
|
await backupsStore.set("backupList", list);
|
||||||
|
await backupsStore.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAuthSettings(): Promise<void> {
|
||||||
|
const current = await settingsStore.get<any>("settings") ?? {};
|
||||||
|
current.serverAuthMode = "NONE";
|
||||||
|
current.serverAuthUser = "";
|
||||||
|
current.serverAuthPass = "";
|
||||||
|
await settingsStore.set("settings", current);
|
||||||
|
await settingsStore.save();
|
||||||
|
localStorage.removeItem("moku-credential-vault");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
|
let themeStyleEl: HTMLStyleElement | null = null;
|
||||||
|
let mediaQuery: MediaQueryList | null = null;
|
||||||
|
let mediaHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function applyTheme() {
|
||||||
|
const themeId = store.settings.theme ?? "dark";
|
||||||
|
const isCustom = themeId.startsWith("custom:");
|
||||||
|
|
||||||
|
if (!isCustom) {
|
||||||
|
themeStyleEl?.remove();
|
||||||
|
themeStyleEl = null;
|
||||||
|
document.documentElement.setAttribute("data-theme", themeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
||||||
|
if (!custom) {
|
||||||
|
themeStyleEl?.remove();
|
||||||
|
themeStyleEl = null;
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vars = Object.entries(custom.tokens)
|
||||||
|
.map(([k, v]) => ` --${k}: ${v};`)
|
||||||
|
.join("\n");
|
||||||
|
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
||||||
|
|
||||||
|
if (!themeStyleEl) {
|
||||||
|
themeStyleEl = document.createElement("style");
|
||||||
|
themeStyleEl.id = "moku-custom-theme";
|
||||||
|
document.head.appendChild(themeStyleEl);
|
||||||
|
}
|
||||||
|
themeStyleEl.textContent = css;
|
||||||
|
document.documentElement.setAttribute("data-theme", "custom");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySystemTheme(dark: boolean) {
|
||||||
|
const themeId = dark
|
||||||
|
? (store.settings.systemThemeDark ?? "dark")
|
||||||
|
: (store.settings.systemThemeLight ?? "light");
|
||||||
|
updateSettings({ theme: themeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountSystemThemeSync() {
|
||||||
|
if (mediaQuery && mediaHandler) {
|
||||||
|
mediaQuery.removeEventListener("change", mediaHandler);
|
||||||
|
mediaHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.settings.systemThemeSync) return;
|
||||||
|
|
||||||
|
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
||||||
|
mediaQuery.addEventListener("change", mediaHandler);
|
||||||
|
applySystemTheme(mediaQuery.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmountSystemThemeSync() {
|
||||||
|
if (mediaQuery && mediaHandler) {
|
||||||
|
mediaQuery.removeEventListener("change", mediaHandler);
|
||||||
|
mediaHandler = null;
|
||||||
|
mediaQuery = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
|
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
||||||
|
|
||||||
|
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
|
if (ms === 0) return;
|
||||||
|
timer = setTimeout(onIdle, ms);
|
||||||
|
onActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
|
||||||
|
reset();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './idle';
|
||||||
|
export * from './zoom';
|
||||||
|
export * from './touchscreen';
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
export interface LongPressOptions {
|
||||||
|
onLongPress: (e: PointerEvent) => void;
|
||||||
|
duration?: number;
|
||||||
|
moveThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
||||||
|
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
let fired = false;
|
||||||
|
|
||||||
|
function start(e: PointerEvent) {
|
||||||
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
|
startX = e.clientX; startY = e.clientY; fired = false;
|
||||||
|
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
|
||||||
|
}
|
||||||
|
function move(e: PointerEvent) {
|
||||||
|
if (!timer) return;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
|
||||||
|
}
|
||||||
|
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", start);
|
||||||
|
node.addEventListener("pointermove", move);
|
||||||
|
node.addEventListener("pointerup", cancel);
|
||||||
|
node.addEventListener("pointerleave", cancel);
|
||||||
|
node.addEventListener("pointercancel",cancel);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fired() { return fired; },
|
||||||
|
destroy() {
|
||||||
|
cancel();
|
||||||
|
node.removeEventListener("pointerdown", start);
|
||||||
|
node.removeEventListener("pointermove", move);
|
||||||
|
node.removeEventListener("pointerup", cancel);
|
||||||
|
node.removeEventListener("pointerleave", cancel);
|
||||||
|
node.removeEventListener("pointercancel",cancel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TapOptions {
|
||||||
|
onTap: (e: PointerEvent) => void;
|
||||||
|
onDoubleTap?: (e: PointerEvent) => void;
|
||||||
|
doubleTapGap?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tap(node: HTMLElement, opts: TapOptions) {
|
||||||
|
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
|
||||||
|
let lastTap = 0;
|
||||||
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startX = 0, startY = 0;
|
||||||
|
const SLOP = 8;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
|
||||||
|
function up(e: PointerEvent) {
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (onDoubleTap && now - lastTap < doubleTapGap) {
|
||||||
|
if (pending) { clearTimeout(pending); pending = null; }
|
||||||
|
onDoubleTap(e);
|
||||||
|
lastTap = 0;
|
||||||
|
} else {
|
||||||
|
lastTap = now;
|
||||||
|
if (onDoubleTap) {
|
||||||
|
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
|
||||||
|
} else {
|
||||||
|
onTap(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeOptions {
|
||||||
|
onSwipeLeft?: (e: PointerEvent) => void;
|
||||||
|
onSwipeRight?: (e: PointerEvent) => void;
|
||||||
|
onSwipeUp?: (e: PointerEvent) => void;
|
||||||
|
onSwipeDown?: (e: PointerEvent) => void;
|
||||||
|
threshold?: number;
|
||||||
|
lockAxis?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swipe(node: HTMLElement, opts: SwipeOptions) {
|
||||||
|
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
|
||||||
|
let startX = 0, startY = 0, active = false;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) {
|
||||||
|
if (e.pointerType === "mouse") return;
|
||||||
|
startX = e.clientX; startY = e.clientY; active = true;
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
function up(e: PointerEvent) {
|
||||||
|
if (!active) return; active = false;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
const ax = Math.abs(dx), ay = Math.abs(dy);
|
||||||
|
if (Math.max(ax, ay) < threshold) return;
|
||||||
|
if (lockAxis && ax > ay) {
|
||||||
|
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
|
||||||
|
} else if (lockAxis && ay >= ax) {
|
||||||
|
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
|
||||||
|
} else {
|
||||||
|
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
|
||||||
|
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cancel() { active = false; }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
node.addEventListener("pointercancel", cancel);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
node.removeEventListener("pointercancel", cancel);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchOptions {
|
||||||
|
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||||
|
onPinchEnd?: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchGestureOptions {
|
||||||
|
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||||
|
onPinchEnd?: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchGesture {
|
||||||
|
onPointerDown: (e: PointerEvent) => void;
|
||||||
|
onPointerMove: (e: PointerEvent) => void;
|
||||||
|
onPointerUp: (e: PointerEvent) => void;
|
||||||
|
isPinching: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
|
||||||
|
const { onPinch, onPinchEnd } = opts;
|
||||||
|
const pointers = new Map<number, PointerEvent>();
|
||||||
|
let initDist = 0;
|
||||||
|
|
||||||
|
function pdist(a: PointerEvent, b: PointerEvent) {
|
||||||
|
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
function pmid(a: PointerEvent, b: PointerEvent) {
|
||||||
|
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
pointers.set(e.pointerId, e);
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
initDist = pdist(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!pointers.has(e.pointerId)) return;
|
||||||
|
pointers.set(e.pointerId, e);
|
||||||
|
if (pointers.size !== 2 || initDist === 0) return;
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
onPinch(pdist(a, b) / initDist, pmid(a, b));
|
||||||
|
}
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (pointers.size === 2 && onPinchEnd) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
onPinchEnd(pdist(a, b) / initDist);
|
||||||
|
}
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
initDist = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
||||||
|
const gesture = createPinchGesture(opts);
|
||||||
|
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointermove", gesture.onPointerMove);
|
||||||
|
node.addEventListener("pointerup", gesture.onPointerUp);
|
||||||
|
node.addEventListener("pointercancel", gesture.onPointerUp);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointermove", gesture.onPointerMove);
|
||||||
|
node.removeEventListener("pointerup", gesture.onPointerUp);
|
||||||
|
node.removeEventListener("pointercancel", gesture.onPointerUp);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragScrollOptions {
|
||||||
|
direction?: "x" | "y" | "both";
|
||||||
|
onDragStart?: () => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
|
||||||
|
const { direction = "both", onDragStart, onDragEnd } = opts;
|
||||||
|
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
|
||||||
|
|
||||||
|
function down(e: PointerEvent) {
|
||||||
|
if (e.pointerType === "mouse") return;
|
||||||
|
active = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
scrollX = node.scrollLeft; scrollY = node.scrollTop;
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
onDragStart?.();
|
||||||
|
}
|
||||||
|
function move(e: PointerEvent) {
|
||||||
|
if (!active) return;
|
||||||
|
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
|
||||||
|
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
|
||||||
|
}
|
||||||
|
function up() { if (active) { active = false; onDragEnd?.(); } }
|
||||||
|
|
||||||
|
node.addEventListener("pointerdown", down);
|
||||||
|
node.addEventListener("pointermove", move);
|
||||||
|
node.addEventListener("pointerup", up);
|
||||||
|
node.addEventListener("pointercancel", up);
|
||||||
|
return { destroy() {
|
||||||
|
node.removeEventListener("pointerdown", down);
|
||||||
|
node.removeEventListener("pointermove", move);
|
||||||
|
node.removeEventListener("pointerup", up);
|
||||||
|
node.removeEventListener("pointercancel", up);
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
|
||||||
|
let _appliedZoom: number = -1;
|
||||||
|
let _vhRafId: number | null = null;
|
||||||
|
|
||||||
|
export function applyZoom() {
|
||||||
|
const uiZoom = store.settings.uiZoom ?? 1.0;
|
||||||
|
if (uiZoom === _appliedZoom) return;
|
||||||
|
_appliedZoom = uiZoom;
|
||||||
|
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||||
|
document.documentElement.style.zoom = `${uiZoom * 100}%`;
|
||||||
|
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||||
|
_vhRafId = requestAnimationFrame(() => {
|
||||||
|
_vhRafId = null;
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleZoomKey(e: KeyboardEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
const current = store.settings.uiZoom ?? 1.0;
|
||||||
|
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
|
||||||
|
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
|
||||||
|
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountZoomKey(): () => void {
|
||||||
|
window.addEventListener("keydown", handleZoomKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleZoomKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampZoom(z: number, min: number, max: number): number {
|
||||||
|
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
style: string,
|
||||||
|
out: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!containerEl || style !== "longstrip") return;
|
||||||
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
|
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
out: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!out.el || !containerEl) return;
|
||||||
|
const el = out.el;
|
||||||
|
out.el = null;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const containerTop = containerEl!.getBoundingClientRect().top;
|
||||||
|
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { addToast } from "@store/state.svelte";
|
||||||
|
|
||||||
|
function parse(tag: string): number[] {
|
||||||
|
return tag.replace(/^v/, "").split(".").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(a: number[], b: number[]): number {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdateSilently(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [currentVersion, releases] = await Promise.all([
|
||||||
|
getVersion(),
|
||||||
|
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||||
|
if (!valid.length) return;
|
||||||
|
|
||||||
|
const latestTag = valid
|
||||||
|
.map(r => r.tag_name)
|
||||||
|
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||||
|
.replace(/^v/, "");
|
||||||
|
|
||||||
|
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
||||||
|
addToast({
|
||||||
|
kind: "info",
|
||||||
|
title: `Update available — v${latestTag}`,
|
||||||
|
body: "Open Settings → About to install.",
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
import type { Settings } from "@types";
|
||||||
|
|
||||||
|
export { clsx as cn } from "clsx";
|
||||||
|
|
||||||
|
export function timeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "Just now";
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayLabel(ts: number): string {
|
||||||
|
const d = new Date(ts), now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
|
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||||
|
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReadTime(m: number): string {
|
||||||
|
if (m < 1) return "< 1 min";
|
||||||
|
if (m < 60) return `${m} min`;
|
||||||
|
const h = Math.floor(m / 60), r = m % 60;
|
||||||
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRICT_TAGS: string[] = [
|
||||||
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
|
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODERATE_TAGS: string[] = [
|
||||||
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
|
"18+", "smut", "explicit", "sexual violence",
|
||||||
|
];
|
||||||
|
|
||||||
|
type ContentFilterSettings = Pick<
|
||||||
|
Settings,
|
||||||
|
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||||
|
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||||
|
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||||
|
if (!blockedTags.length) return false;
|
||||||
|
return genre.some(g => {
|
||||||
|
const norm = g.toLowerCase().trim();
|
||||||
|
return blockedTags.some(tag => {
|
||||||
|
const idx = norm.indexOf(tag);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||||
|
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||||
|
return before && after;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHideNsfw(
|
||||||
|
manga: Pick<Manga, "genre" | "source">,
|
||||||
|
settings: ContentFilterSettings,
|
||||||
|
): boolean {
|
||||||
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
|
|
||||||
|
const srcId = manga.source?.id;
|
||||||
|
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||||
|
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||||
|
|
||||||
|
if (srcId && blocked.includes(srcId)) return true;
|
||||||
|
|
||||||
|
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||||
|
|
||||||
|
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||||
|
|
||||||
|
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHideSource(
|
||||||
|
source: Pick<Source, "id" | "isNsfw">,
|
||||||
|
settings: ContentFilterSettings,
|
||||||
|
): boolean {
|
||||||
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
|
|
||||||
|
if (settings.sourceOverridesEnabled) {
|
||||||
|
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||||
|
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.isNsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeSourcesByLang(
|
||||||
|
sources: Source[],
|
||||||
|
preferredLang: string,
|
||||||
|
settings: ContentFilterSettings,
|
||||||
|
applyHide = false,
|
||||||
|
): Source[] {
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.id === "0") continue;
|
||||||
|
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of sources) {
|
||||||
|
if (src.id === "0") continue;
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find(s => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTitle(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "")
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ")
|
||||||
|
.replace(/^(the|a|an)\s+/, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
|
if (!desc) return null;
|
||||||
|
const n = norm(desc);
|
||||||
|
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||||
|
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||||
|
return parts.length ? parts.sort().join("|") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeMangaByTitle<T extends {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
inLibrary?: boolean;
|
||||||
|
downloadCount?: number;
|
||||||
|
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||||
|
const byTitle = new Map<string, number>();
|
||||||
|
const byDesc = new Map<string, number>();
|
||||||
|
const byAuthorDesc = new Map<string, number>();
|
||||||
|
const byId = new Map<number, number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
|
||||||
|
for (const m of items) {
|
||||||
|
const tk = normalizeTitle(m.title);
|
||||||
|
const dk = descFingerprint(m.description);
|
||||||
|
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||||
|
|
||||||
|
const linkedIds = links[m.id] ?? [];
|
||||||
|
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||||
|
const existingIdx =
|
||||||
|
linkedIdx ??
|
||||||
|
byTitle.get(tk) ??
|
||||||
|
(dk ? byDesc.get(dk) : undefined) ??
|
||||||
|
(ak ? byAuthorDesc.get(ak) : undefined);
|
||||||
|
|
||||||
|
if (existingIdx !== undefined) {
|
||||||
|
const existing = out[existingIdx];
|
||||||
|
const mBetter =
|
||||||
|
(m.inLibrary && !existing.inLibrary) ||
|
||||||
|
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||||
|
|
||||||
|
if (mBetter) {
|
||||||
|
out[existingIdx] = m;
|
||||||
|
byTitle.set(tk, existingIdx);
|
||||||
|
byId.set(m.id, existingIdx);
|
||||||
|
if (dk) byDesc.set(dk, existingIdx);
|
||||||
|
if (ak) byAuthorDesc.set(ak, existingIdx);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = out.length;
|
||||||
|
out.push(m);
|
||||||
|
byTitle.set(tk, idx);
|
||||||
|
byId.set(m.id, idx);
|
||||||
|
if (dk) byDesc.set(dk, idx);
|
||||||
|
if (ak) byAuthorDesc.set(ak, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
/* ─────────────────────────────────────────────
|
|
||||||
Moku — Animations
|
|
||||||
───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
@@ -34,26 +30,19 @@
|
|||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
from { background-position: -200% 0; }
|
from { background-position: -200% 0; }
|
||||||
to { background-position: 200% 0; }
|
to { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utility classes */
|
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
|
||||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||||
|
|
||||||
/* Skeleton shimmer */
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||||
90deg,
|
|
||||||
var(--bg-raised) 25%,
|
|
||||||
var(--bg-overlay) 50%,
|
|
||||||
var(--bg-raised) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s ease infinite;
|
animation: shimmer 1.4s ease infinite;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import "./reset.css";
|
||||||
|
@import "./animations.css";
|
||||||
|
@import "./scrollbars.css";
|
||||||
|
@import "./typography.css";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol { list-style: none; }
|
||||||
|
|
||||||
|
img, svg { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
p { margin: 0; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
line-height: var(--leading-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-void: #000000;
|
||||||
|
--bg-base: #080808;
|
||||||
|
--bg-surface: #0d0d0d;
|
||||||
|
--bg-raised: #111111;
|
||||||
|
--bg-overlay: #171717;
|
||||||
|
--bg-subtle: #1e1e1e;
|
||||||
|
|
||||||
|
--border-dim: #252525;
|
||||||
|
--border-base: #303030;
|
||||||
|
--border-strong: #3e3e3e;
|
||||||
|
--border-focus: #5a7a5a;
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e8e6e0;
|
||||||
|
--text-muted: #b0aea8;
|
||||||
|
--text-faint: #6e6c68;
|
||||||
|
--text-disabled: #303030;
|
||||||
|
|
||||||
|
--accent: #7aaa7a;
|
||||||
|
--accent-dim: #2e4a2e;
|
||||||
|
--accent-muted: #1e2e1e;
|
||||||
|
--accent-fg: #bcd8bc;
|
||||||
|
--accent-bright: #9fcf9f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import "./original.css";
|
||||||
|
@import "./dark.css";
|
||||||
|
@import "./light.css";
|
||||||
|
@import "./midnight.css";
|
||||||
|
@import "./warm.css";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[data-theme="light"] {
|
||||||
|
--bg-void: #d8d4ce;
|
||||||
|
--bg-base: #e2deda;
|
||||||
|
--bg-surface: #ece8e2;
|
||||||
|
--bg-raised: #f5f2ec;
|
||||||
|
--bg-overlay: #ffffff;
|
||||||
|
--bg-subtle: #e4e0d8;
|
||||||
|
|
||||||
|
--border-dim: #c4c0b8;
|
||||||
|
--border-base: #b0aca4;
|
||||||
|
--border-strong: #989490;
|
||||||
|
--border-focus: #3a5a3a;
|
||||||
|
|
||||||
|
--text-primary: #080806;
|
||||||
|
--text-secondary: #181612;
|
||||||
|
--text-muted: #38342e;
|
||||||
|
--text-faint: #706c64;
|
||||||
|
--text-disabled: #b0aca4;
|
||||||
|
|
||||||
|
--accent: #2a5a2a;
|
||||||
|
--accent-dim: #b0ccb0;
|
||||||
|
--accent-muted: #c8dcc8;
|
||||||
|
--accent-fg: #183818;
|
||||||
|
--accent-bright: #1e4e1e;
|
||||||
|
|
||||||
|
--color-error: #8a1a1a;
|
||||||
|
--color-error-bg: #f8e0e0;
|
||||||
|
--color-read: #e0dcd4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="midnight"] {
|
||||||
|
--bg-void: #050810;
|
||||||
|
--bg-base: #080c18;
|
||||||
|
--bg-surface: #0c1020;
|
||||||
|
--bg-raised: #101428;
|
||||||
|
--bg-overlay: #151a30;
|
||||||
|
--bg-subtle: #1a2038;
|
||||||
|
|
||||||
|
--border-dim: #1a2035;
|
||||||
|
--border-base: #222840;
|
||||||
|
--border-strong: #2c3450;
|
||||||
|
--border-focus: #4a5c8a;
|
||||||
|
|
||||||
|
--text-primary: #eeeef8;
|
||||||
|
--text-secondary: #c0c4d8;
|
||||||
|
--text-muted: #808498;
|
||||||
|
--text-faint: #404860;
|
||||||
|
--text-disabled: #202840;
|
||||||
|
|
||||||
|
--accent: #6a7ab8;
|
||||||
|
--accent-dim: #252d50;
|
||||||
|
--accent-muted: #181e38;
|
||||||
|
--accent-fg: #a8b4e8;
|
||||||
|
--accent-bright: #8896d0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[data-theme="original"] {
|
||||||
|
--bg-void: #080808;
|
||||||
|
--bg-base: #0c0c0c;
|
||||||
|
--bg-surface: #101010;
|
||||||
|
--bg-raised: #151515;
|
||||||
|
--bg-overlay: #1a1a1a;
|
||||||
|
--bg-subtle: #202020;
|
||||||
|
|
||||||
|
--border-dim: #1c1c1c;
|
||||||
|
--border-base: #242424;
|
||||||
|
--border-strong: #2e2e2e;
|
||||||
|
--border-focus: #4a5c4a;
|
||||||
|
|
||||||
|
--text-primary: #f0efec;
|
||||||
|
--text-secondary: #c8c6c0;
|
||||||
|
--text-muted: #8a8880;
|
||||||
|
--text-faint: #4e4d4a;
|
||||||
|
--text-disabled: #2a2a28;
|
||||||
|
|
||||||
|
--accent: #6b8f6b;
|
||||||
|
--accent-dim: #2a3d2a;
|
||||||
|
--accent-muted: #1a251a;
|
||||||
|
--accent-fg: #a8c4a8;
|
||||||
|
--accent-bright: #8fb88f;
|
||||||
|
|
||||||
|
--color-error: #c47a7a;
|
||||||
|
--color-error-bg: #1f1212;
|
||||||
|
--color-success: #7aab7a;
|
||||||
|
--color-info: #7a9ec4;
|
||||||
|
--color-info-bg: #121a1f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[data-theme="warm"] {
|
||||||
|
--bg-void: #0c0a06;
|
||||||
|
--bg-base: #100e08;
|
||||||
|
--bg-surface: #16130c;
|
||||||
|
--bg-raised: #1c1810;
|
||||||
|
--bg-overlay: #221e14;
|
||||||
|
--bg-subtle: #28241a;
|
||||||
|
|
||||||
|
--border-dim: #201c10;
|
||||||
|
--border-base: #2c2818;
|
||||||
|
--border-strong: #3a3420;
|
||||||
|
--border-focus: #6a5a30;
|
||||||
|
|
||||||
|
--text-primary: #f5f0e0;
|
||||||
|
--text-secondary: #d8d0b0;
|
||||||
|
--text-muted: #988c60;
|
||||||
|
--text-faint: #584e30;
|
||||||
|
--text-disabled: #302a18;
|
||||||
|
|
||||||
|
--accent: #c0902a;
|
||||||
|
--accent-dim: #3a2c10;
|
||||||
|
--accent-muted: #261e0c;
|
||||||
|
--accent-fg: #e0b860;
|
||||||
|
--accent-bright: #d0a040;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
--bg-void: #080808;
|
||||||
|
--bg-base: #0c0c0c;
|
||||||
|
--bg-surface: #101010;
|
||||||
|
--bg-raised: #151515;
|
||||||
|
--bg-overlay: #1a1a1a;
|
||||||
|
--bg-subtle: #202020;
|
||||||
|
|
||||||
|
--border-dim: #1c1c1c;
|
||||||
|
--border-base: #242424;
|
||||||
|
--border-strong: #2e2e2e;
|
||||||
|
--border-focus: #4a5c4a;
|
||||||
|
|
||||||
|
--text-primary: #f0efec;
|
||||||
|
--text-secondary: #c8c6c0;
|
||||||
|
--text-muted: #8a8880;
|
||||||
|
--text-faint: #4e4d4a;
|
||||||
|
--text-disabled: #2a2a28;
|
||||||
|
|
||||||
|
--accent: #6b8f6b;
|
||||||
|
--accent-dim: #2a3d2a;
|
||||||
|
--accent-muted: #1a251a;
|
||||||
|
--accent-fg: #a8c4a8;
|
||||||
|
--accent-bright: #8fb88f;
|
||||||
|
|
||||||
|
--color-error: #c47a7a;
|
||||||
|
--color-error-bg: #1f1212;
|
||||||
|
--color-success: #7aab7a;
|
||||||
|
--color-info: #7a9ec4;
|
||||||
|
--color-info-bg: #121a1f;
|
||||||
|
--color-read: #2e2e2c;
|
||||||
|
|
||||||
|
--dot-active: var(--accent);
|
||||||
|
--dot-inactive: var(--text-faint);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@import "./colors.css";
|
||||||
|
@import "./typography.css";
|
||||||
|
@import "./spacing.css";
|
||||||
|
@import "./radius.css";
|
||||||
|
@import "./motion.css";
|
||||||
|
@import "./shadows.css";
|
||||||
|
@import "./zindex.css";
|
||||||
|
@import "../themes/index.css";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--t-fast: 0.08s ease;
|
||||||
|
--t-base: 0.14s ease;
|
||||||
|
--t-slow: 0.22s ease;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:root {
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 5px;
|
||||||
|
--radius-lg: 7px;
|
||||||
|
--radius-xl: 10px;
|
||||||
|
--radius-2xl: 14px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:root {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
:root {
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 20px;
|
||||||
|
--sp-6: 24px;
|
||||||
|
--sp-8: 32px;
|
||||||
|
--sp-10: 40px;
|
||||||
|
|
||||||
|
--sidebar-width: 52px;
|
||||||
|
--titlebar-height: 36px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
:root {
|
||||||
|
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
|
||||||
|
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--text-2xs: 10px;
|
||||||
|
--text-xs: 11px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 13px;
|
||||||
|
--text-md: 14px;
|
||||||
|
--text-lg: 15px;
|
||||||
|
--text-xl: 17px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semi: 600;
|
||||||
|
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.3;
|
||||||
|
--leading-snug: 1.45;
|
||||||
|
--leading-base: 1.6;
|
||||||
|
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.06em;
|
||||||
|
--tracking-wider: 0.1em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--z-reader: 50;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-settings: 150;
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
|
||||||
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
|
||||||
|
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
|
||||||
|
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
|
||||||
|
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
|
||||||
|
import type { Manga, Source, Category } from "@types/index";
|
||||||
|
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||||
|
import {
|
||||||
|
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
||||||
|
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
||||||
|
} from "@features/discover/lib/searchFilter";
|
||||||
|
|
||||||
|
const prevNavPage = store.navPage;
|
||||||
|
const tags = $derived(parseTags(store.genreFilter));
|
||||||
|
const primaryTag = $derived(tags[0] ?? "");
|
||||||
|
const label = $derived(tagsLabel(tags));
|
||||||
|
|
||||||
|
let libraryManga: Manga[] = $state([]);
|
||||||
|
let sourceManga: Manga[] = $state([]);
|
||||||
|
let loadingInitial = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let visibleCount = $state(PAGE_SIZE);
|
||||||
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let categories: Category[] = $state([]);
|
||||||
|
let catsLoaded = false;
|
||||||
|
|
||||||
|
const nextPageMap = new Map<string, number>();
|
||||||
|
let sources: Source[] = $state([]);
|
||||||
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||||
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
|
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||||
|
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||||
|
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||||
|
|
||||||
|
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||||
|
|
||||||
|
async function load(filter: string) {
|
||||||
|
abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortCtrl = ctrl;
|
||||||
|
loadingInitial = true;
|
||||||
|
sourceManga = [];
|
||||||
|
libraryManga = [];
|
||||||
|
visibleCount = PAGE_SIZE;
|
||||||
|
nextPageMap.clear();
|
||||||
|
|
||||||
|
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||||
|
const t = parseTags(filter);
|
||||||
|
const pt = t[0] ?? "";
|
||||||
|
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
Promise.all([
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||||
|
]).then(([all, lib]) => {
|
||||||
|
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
|
||||||
|
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
|
||||||
|
}),
|
||||||
|
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
||||||
|
|
||||||
|
cache.get(
|
||||||
|
CACHE_KEYS.SOURCES,
|
||||||
|
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
||||||
|
Infinity,
|
||||||
|
).then(async (allSources) => {
|
||||||
|
const srcs = allSources.slice(0, MAX_SOURCES);
|
||||||
|
sources = srcs;
|
||||||
|
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||||
|
|
||||||
|
await runConcurrent(srcs, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", t);
|
||||||
|
const pageItems: Manga[] = [];
|
||||||
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||||
|
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
|
pageKey,
|
||||||
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal,
|
||||||
|
).then((d) => d.fetchSourceManga),
|
||||||
|
).catch(() => null);
|
||||||
|
if (!result || ctrl.signal.aborted) break;
|
||||||
|
ps.add(page);
|
||||||
|
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||||
|
pageItems.push(...matching);
|
||||||
|
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||||
|
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||||
|
}
|
||||||
|
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||||
|
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
|
||||||
|
loadingInitial = false;
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted) loadingInitial = false;
|
||||||
|
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMore) return;
|
||||||
|
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
|
||||||
|
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||||
|
if (!srcs.length) return;
|
||||||
|
loadingMore = true;
|
||||||
|
abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortCtrl = ctrl;
|
||||||
|
try {
|
||||||
|
await runConcurrent(srcs, async (src) => {
|
||||||
|
const page = nextPageMap.get(src.id)!;
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||||
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||||
|
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
|
pageKey,
|
||||||
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
|
||||||
|
).then((d) => d.fetchSourceManga),
|
||||||
|
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
ps.add(page);
|
||||||
|
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
|
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||||
|
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||||
|
}, ctrl.signal);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
|
if (!catsLoaded) {
|
||||||
|
catsLoaded = true;
|
||||||
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
|
.then((d) => { categories = d.categories.nodes.filter((c) => c.id !== 0); })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
|
icon: BookmarkSimple,
|
||||||
|
disabled: m.inLibrary,
|
||||||
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => {
|
||||||
|
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
...(categories.length > 0 ? [
|
||||||
|
{ separator: true } as MenuEntry,
|
||||||
|
...categories.map((cat): MenuEntry => ({
|
||||||
|
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
|
icon: Folder,
|
||||||
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
|
})),
|
||||||
|
] : []),
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "New folder & add",
|
||||||
|
icon: FolderSimplePlus,
|
||||||
|
onClick: async () => {
|
||||||
|
const name = prompt("Folder name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const res = await gql<{ createCategory: { category: Category } }>(
|
||||||
|
CREATE_CATEGORY, { name: name.trim() },
|
||||||
|
).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
|
categories = [...categories, cat];
|
||||||
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => () => { abortCtrl?.abort(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||||
|
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||||
|
</button>
|
||||||
|
<span class="title">{label}</span>
|
||||||
|
{#if !loadingInitial || filtered.length > 0}
|
||||||
|
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if !loadingInitial && hasMoreNetwork}
|
||||||
|
<span class="loading-hint">More loading…</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingInitial && filtered.length === 0}
|
||||||
|
<div class="grid">
|
||||||
|
{#each Array(50) as _}
|
||||||
|
<div class="card-skeleton">
|
||||||
|
<div class="cover-skeleton skeleton"></div>
|
||||||
|
<div class="title-skeleton skeleton"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="empty">No manga found for "{label}".</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each visibleItems as m, i (m.id)}
|
||||||
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||||
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="card-title">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#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}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ctx}
|
||||||
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||||
|
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||||
|
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
|
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||||
|
.card:hover .card-title { color: var(--text-primary); }
|
||||||
|
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); 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-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%; }
|
||||||
|
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||||
|
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||||
|
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
|
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
import type { CachedManga } from "@features/discover/lib/searchFilter";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
availableLangs: string[];
|
||||||
|
hasMultipleLangs: boolean;
|
||||||
|
loadingSources: boolean;
|
||||||
|
pendingPrefill: string;
|
||||||
|
popularResults: (Manga & { _priority: number })[];
|
||||||
|
popularLoading: boolean;
|
||||||
|
sourceCache: Map<number, CachedManga>;
|
||||||
|
onPrefillConsumed: () => void;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||||
|
pendingPrefill, popularResults, popularLoading,
|
||||||
|
sourceCache,
|
||||||
|
onPrefillConsumed, onPreview,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
|
let kw_query = $state("");
|
||||||
|
let kw_results: SourceResult[] = $state([]);
|
||||||
|
let kw_showAdvanced = $state(false);
|
||||||
|
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||||
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
|
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
interface SourceResult {
|
||||||
|
source: Source;
|
||||||
|
mangas: Manga[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (allSources.length) {
|
||||||
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
|
kw_selectedLangs = available.has(preferredLang)
|
||||||
|
? new Set([preferredLang])
|
||||||
|
: new Set(availableLangs.slice(0, 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||||
|
const q = pendingPrefill;
|
||||||
|
onPrefillConsumed();
|
||||||
|
kw_query = q;
|
||||||
|
kwDoSearch(q);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const q = kw_query;
|
||||||
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
|
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
||||||
|
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
||||||
|
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
||||||
|
});
|
||||||
|
|
||||||
|
function kwGetVisibleSources(): Source[] {
|
||||||
|
let filtered = allSources;
|
||||||
|
if (kw_selectedLangs.size > 0)
|
||||||
|
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
|
if (store.settings.contentLevel !== "unrestricted")
|
||||||
|
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kwDoSearch(q: string) {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const visible = kwGetVisibleSources();
|
||||||
|
if (!visible.length) return;
|
||||||
|
kw_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
kw_abortCtrl = ctrl;
|
||||||
|
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||||
|
kw_results = initial;
|
||||||
|
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
|
||||||
|
await runConcurrent(visible, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const idx = indexBySrcId.get(src.id)!;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
|
preloadBlobUrls(
|
||||||
|
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
const next = [...kw_results];
|
||||||
|
next[idx] = { ...next[idx], mangas, loading: false };
|
||||||
|
kw_results = next;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
|
const next = [...kw_results];
|
||||||
|
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
|
||||||
|
kw_results = next;
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kwToggleLang(lang: string) {
|
||||||
|
const next = new Set(kw_selectedLangs);
|
||||||
|
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
|
||||||
|
else next.add(lang);
|
||||||
|
kw_selectedLangs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||||
|
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||||
|
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||||
|
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
||||||
|
|
||||||
|
const kw_flatResults = $derived.by(() => {
|
||||||
|
const all = kw_results.flatMap((r) =>
|
||||||
|
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
||||||
|
);
|
||||||
|
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
|
||||||
|
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
kw_abortCtrl?.abort();
|
||||||
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="keywordBar">
|
||||||
|
<div class="searchBar">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={kw_inputEl}
|
||||||
|
bind:value={kw_query}
|
||||||
|
class="searchInput"
|
||||||
|
placeholder="Search across sources…"
|
||||||
|
use:focusOnMount
|
||||||
|
/>
|
||||||
|
{#if kw_anyLoading}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else if kw_query}
|
||||||
|
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasMultipleLangs}
|
||||||
|
<button
|
||||||
|
class="advancedBtn"
|
||||||
|
class:advancedBtnActive={kw_showAdvanced}
|
||||||
|
title="Language & filter options"
|
||||||
|
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasMultipleLangs && kw_showAdvanced}
|
||||||
|
<div class="advancedPanel">
|
||||||
|
<div class="advancedHeader">
|
||||||
|
<span class="advancedTitle">Languages</span>
|
||||||
|
<div class="advancedActions">
|
||||||
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||||
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="langGrid">
|
||||||
|
{#each availableLangs as lang (lang)}
|
||||||
|
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
|
||||||
|
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="advancedDivider"></div>
|
||||||
|
<div class="advancedFooter">
|
||||||
|
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !kw_query.trim()}
|
||||||
|
{#if popularLoading && popularResults.length === 0}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if popularResults.length > 0}
|
||||||
|
<div class="searchHeader">
|
||||||
|
<span class="searchLabel">Popular right now</span>
|
||||||
|
</div>
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each popularResults as m (m.id)}
|
||||||
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
|
<div class="srchCoverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||||
|
<div class="srchGradient"></div>
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
<div class="srchFooter">
|
||||||
|
<p class="srchTitle">{m.title}</p>
|
||||||
|
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if popularLoading}
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Search across sources</p>
|
||||||
|
<p class="emptyHint">
|
||||||
|
{#if hasMultipleLangs}
|
||||||
|
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
|
||||||
|
{:else}
|
||||||
|
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if kw_flatResults.length > 0}
|
||||||
|
<div class="searchHeader">
|
||||||
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each kw_flatResults as m (m.id)}
|
||||||
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
|
<div class="srchCoverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||||
|
<div class="srchGradient"></div>
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
<div class="srchFooter">
|
||||||
|
<p class="srchTitle">{m.title}</p>
|
||||||
|
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if kw_anyLoading}
|
||||||
|
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if kw_anyLoading}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if kw_allDone && !kw_hasResults}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="emptyText">No results for "{kw_query.trim()}"</p>
|
||||||
|
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||||
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.clearBtn:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||||
|
.advancedActions { display: flex; gap: var(--sp-2); }
|
||||||
|
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||||
|
.advancedLink:hover { opacity: 0.75; }
|
||||||
|
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.advancedDivider { height: 1px; background: var(--border-dim); }
|
||||||
|
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
|
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||||
|
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||||
|
|
||||||
|
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
|
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||||
|
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||||
|
.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); }
|
||||||
|
.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%; }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, untrack } from "svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { GET_SOURCES } from "@api/queries/extensions";
|
||||||
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
|
import { FETCH_MANGA } from "@api/mutations/manga";
|
||||||
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
|
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||||
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
|
import { shouldHideNsfw } from "@core/util";
|
||||||
|
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||||
|
import {
|
||||||
|
toCachedManga,
|
||||||
|
type CachedManga,
|
||||||
|
} from "@features/discover/lib/searchFilter";
|
||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
|
||||||
|
import KeywordTab from "./KeywordTab.svelte";
|
||||||
|
import TagTab from "./TagTab.svelte";
|
||||||
|
import SourceTab from "./SourceTab.svelte";
|
||||||
|
|
||||||
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
|
|
||||||
|
const TABS = ["keyword", "tag", "source"] as const;
|
||||||
|
|
||||||
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
|
|
||||||
|
function updateIndicator() {
|
||||||
|
if (!tabsEl) return;
|
||||||
|
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||||
|
if (!active) return;
|
||||||
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
|
const SEARCH_PAGES = 3;
|
||||||
|
const SEARCH_LIMIT = 200;
|
||||||
|
const SEARCH_BATCH = 20;
|
||||||
|
const POPULAR_CACHE_PAGES = 3;
|
||||||
|
|
||||||
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
|
let tab: SearchTab = $state("keyword");
|
||||||
|
|
||||||
|
let pendingPrefill = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
if (store.searchPrefill) {
|
||||||
|
const prefill = store.searchPrefill;
|
||||||
|
untrack(() => {
|
||||||
|
pendingPrefill = prefill;
|
||||||
|
tab = "keyword";
|
||||||
|
setSearchPrefill("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let allSources: Source[] = $state([]);
|
||||||
|
let localSource: Source | null = $state(null);
|
||||||
|
let loadingSources = $state(false);
|
||||||
|
|
||||||
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
|
loadingSources = true;
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => {
|
||||||
|
const nodes = d.sources.nodes;
|
||||||
|
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
|
||||||
|
allSources = nodes.filter((src: Source) => src.id !== "0");
|
||||||
|
startSourceCacheBuild();
|
||||||
|
popularStart(allSources);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
|
let popular_raw: Manga[] = $state([]);
|
||||||
|
let popular_loading = $state(false);
|
||||||
|
let popular_moreLoading = $state(false);
|
||||||
|
let popular_abortCtrl: AbortController | null = null;
|
||||||
|
let popular_sourcePool: Source[] = $state([]);
|
||||||
|
let popular_sourceCursor = $state(0);
|
||||||
|
let popular_hasMore = $state(false);
|
||||||
|
let popular_seenIds = new Set<number>();
|
||||||
|
let popular_seenTitles = new Set<string>();
|
||||||
|
|
||||||
|
const popular_results: (Manga & { _priority: number })[] = $derived(
|
||||||
|
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
function popular_push(incoming: Manga[]) {
|
||||||
|
const toAdd: Manga[] = [];
|
||||||
|
for (const m of incoming) {
|
||||||
|
if (shouldHideNsfw(m, store.settings)) continue;
|
||||||
|
if (popular_seenIds.has(m.id)) continue;
|
||||||
|
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
||||||
|
if (popular_seenTitles.has(norm)) continue;
|
||||||
|
popular_seenIds.add(m.id);
|
||||||
|
popular_seenTitles.add(norm);
|
||||||
|
toAdd.push(m);
|
||||||
|
}
|
||||||
|
if (!toAdd.length) return;
|
||||||
|
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function popular_fanOut(signal: AbortSignal) {
|
||||||
|
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
||||||
|
if (!batch.length) { popular_hasMore = false; return; }
|
||||||
|
|
||||||
|
await runConcurrent(batch, async (src) => {
|
||||||
|
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const key = `${src.id}|POPULAR|All:p${page}`;
|
||||||
|
let mangas: Manga[];
|
||||||
|
if (store.searchCache?.has(key)) {
|
||||||
|
mangas = store.searchCache.get(key)!;
|
||||||
|
} else {
|
||||||
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "POPULAR", page, query: null },
|
||||||
|
signal,
|
||||||
|
).then((d) => d.fetchSourceManga).catch(() => null);
|
||||||
|
if (!result || signal.aborted) break;
|
||||||
|
mangas = result.mangas;
|
||||||
|
store.searchCache?.set(key, mangas);
|
||||||
|
if (!result.hasNextPage) { popular_push(mangas); break; }
|
||||||
|
}
|
||||||
|
popular_push(mangas);
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
popular_sourceCursor += batch.length;
|
||||||
|
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function popularStart(sources: Source[]) {
|
||||||
|
if (popular_raw.length > 0) return;
|
||||||
|
popular_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
popular_abortCtrl = ctrl;
|
||||||
|
popular_seenIds.clear();
|
||||||
|
popular_seenTitles.clear();
|
||||||
|
popular_raw = [];
|
||||||
|
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
|
||||||
|
popular_sourceCursor = 0;
|
||||||
|
popular_hasMore = false;
|
||||||
|
popular_moreLoading = false;
|
||||||
|
popular_loading = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
|
||||||
|
await popular_fanOut(ctrl.signal);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!ctrl.signal.aborted) popular_loading = false;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sourceCache = new Map<number, CachedManga>();
|
||||||
|
|
||||||
|
let sourceCacheReady = $state(false);
|
||||||
|
let sourceCacheLoading = $state(false);
|
||||||
|
let sourceCacheEnriching = $state(false);
|
||||||
|
let sourceCacheAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
|
||||||
|
const tasks: { src: Source; page: number }[] = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
||||||
|
}
|
||||||
|
await runConcurrent(tasks, async ({ src, page }) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
|
||||||
|
let mangas: Manga[];
|
||||||
|
if (store.searchCache?.has(cacheKey)) {
|
||||||
|
mangas = store.searchCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "POPULAR", page },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
mangas = d.fetchSourceManga.mangas;
|
||||||
|
store.searchCache?.set(cacheKey, mangas);
|
||||||
|
}
|
||||||
|
for (const m of mangas) {
|
||||||
|
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichGenres(signal: AbortSignal) {
|
||||||
|
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
|
||||||
|
if (!unenriched.length) return;
|
||||||
|
sourceCacheEnriching = true;
|
||||||
|
await runConcurrent(unenriched, async (entry) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
|
||||||
|
FETCH_MANGA, { id: entry.id }, signal,
|
||||||
|
);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const updated = sourceCache.get(entry.id);
|
||||||
|
if (updated) {
|
||||||
|
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||||
|
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||||
|
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
|
||||||
|
updated.genreEnriched = true;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
|
const updated = sourceCache.get(entry.id);
|
||||||
|
if (updated) updated.genreEnriched = true;
|
||||||
|
}
|
||||||
|
}, signal);
|
||||||
|
if (!signal.aborted) sourceCacheEnriching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSourceCacheBuild() {
|
||||||
|
if (sourceCacheLoading || sourceCacheReady) return;
|
||||||
|
sourceCacheAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
sourceCacheAbort = ctrl;
|
||||||
|
sourceCacheLoading = true;
|
||||||
|
sourceCache.clear();
|
||||||
|
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||||
|
buildSourceCache(dedupedSources, ctrl.signal)
|
||||||
|
.then(() => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
sourceCacheReady = true;
|
||||||
|
sourceCacheLoading = false;
|
||||||
|
enrichGenres(ctrl.signal);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
sourceCacheLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
popular_abortCtrl?.abort();
|
||||||
|
sourceCacheAbort?.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root anim-fade-in">
|
||||||
|
<div class="header">
|
||||||
|
<span class="heading">Search</span>
|
||||||
|
|
||||||
|
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||||
|
{#if anims && tabIndicator.width > 0}
|
||||||
|
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
Keyword
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:tabActive={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "tag"; }}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||||
|
</svg>
|
||||||
|
Tags
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:tabActive={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "source"; }}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||||
|
</svg>
|
||||||
|
Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === "keyword"}
|
||||||
|
<KeywordTab
|
||||||
|
{allSources}
|
||||||
|
{availableLangs}
|
||||||
|
{hasMultipleLangs}
|
||||||
|
{loadingSources}
|
||||||
|
{pendingPrefill}
|
||||||
|
popularResults={popular_results}
|
||||||
|
popularLoading={popular_loading}
|
||||||
|
{sourceCache}
|
||||||
|
query={store.searchQuery}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
|
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||||
|
onPreview={setPreviewManga}
|
||||||
|
/>
|
||||||
|
{:else if tab === "tag"}
|
||||||
|
<TagTab
|
||||||
|
{allSources}
|
||||||
|
{sourceCache}
|
||||||
|
{sourceCacheReady}
|
||||||
|
{sourceCacheLoading}
|
||||||
|
{sourceCacheEnriching}
|
||||||
|
onPreview={setPreviewManga}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<SourceTab
|
||||||
|
{allSources}
|
||||||
|
{availableLangs}
|
||||||
|
{loadingSources}
|
||||||
|
{localSource}
|
||||||
|
onPreview={setPreviewManga}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
|
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
||||||
|
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||||
|
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
||||||
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
|
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||||
|
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
availableLangs: string[];
|
||||||
|
loadingSources: boolean;
|
||||||
|
localSource: Source | null;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
}
|
||||||
|
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
|
||||||
|
|
||||||
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
|
let src_selectedLang = $state(preferredLang || "all");
|
||||||
|
let src_activeSource: Source | null = $state(null);
|
||||||
|
let src_browseResults: Manga[] = $state([]);
|
||||||
|
let src_loadingBrowse = $state(false);
|
||||||
|
let src_browseQuery = $state("");
|
||||||
|
let src_submitted = $state("");
|
||||||
|
let src_hasNextPage = $state(false);
|
||||||
|
let src_currentPage = $state(1);
|
||||||
|
let src_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
|
let ctx_x = $state(0);
|
||||||
|
let ctx_y = $state(0);
|
||||||
|
let ctx_source: Source | null = $state(null);
|
||||||
|
|
||||||
|
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
|
||||||
|
const pinnedSources = $derived(
|
||||||
|
pinnedIds
|
||||||
|
.map(id => allSources.find(s => s.id === id))
|
||||||
|
.filter((s): s is Source => !!s)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!allSources.length) return;
|
||||||
|
const langs = new Set(allSources.map((s) => s.lang));
|
||||||
|
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
|
||||||
|
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const src_visibleSources = $derived.by(() => {
|
||||||
|
const hide = (s: Source) => shouldHideSource(s, store.settings);
|
||||||
|
if (src_selectedLang !== "all") {
|
||||||
|
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||||
|
}
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of allSources) {
|
||||||
|
if (hide(s)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
|
src_abortCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
src_abortCtrl = ctrl;
|
||||||
|
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type, page, query: q ?? null },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
|
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||||
|
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||||
|
src_currentPage = page;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) src_loadingBrowse = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcSelectSource(src: Source) {
|
||||||
|
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
|
||||||
|
srcFetchBrowse(src, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcHandleSearch() {
|
||||||
|
if (!src_activeSource || !src_browseQuery.trim()) return;
|
||||||
|
src_submitted = src_browseQuery.trim();
|
||||||
|
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function srcClearSearch() {
|
||||||
|
src_browseQuery = ""; src_submitted = "";
|
||||||
|
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, src: Source) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
|
||||||
|
}
|
||||||
|
function closeCtx() { ctx_source = null; }
|
||||||
|
|
||||||
|
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splitRoot">
|
||||||
|
<div class="splitSidebar">
|
||||||
|
<div class="srcLangRow">
|
||||||
|
<span class="langPocketLabel">Language</span>
|
||||||
|
<select class="langSelect" bind:value={src_selectedLang}>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{#each availableLangs as lang (lang)}
|
||||||
|
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingSources}
|
||||||
|
<div class="splitLoading">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="splitList">
|
||||||
|
{#if localSource}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === localSource.id}
|
||||||
|
onclick={() => srcSelectSource(localSource)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, localSource)}
|
||||||
|
>
|
||||||
|
<div class="localSourceIcon">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="splitItemLabel">Local Source</span>
|
||||||
|
</button>
|
||||||
|
<div class="localDivider"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pinnedSources.length > 0}
|
||||||
|
<p class="sectionLabel">Pinned</p>
|
||||||
|
{#each pinnedSources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
|
<span class="pinIndicator" title="Pinned">
|
||||||
|
<PushPin size={9} weight="fill" />
|
||||||
|
</span>
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="localDivider"></div>
|
||||||
|
<p class="sectionLabel">All Sources</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each src_visibleSources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
|
{#if src_selectedLang === "all"}
|
||||||
|
<span class="sourceLang">{src.lang.toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if src_visibleSources.length === 0}
|
||||||
|
<p class="splitEmpty">No sources for this language</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="splitContent">
|
||||||
|
{#if !src_activeSource}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Browse a source</p>
|
||||||
|
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="splitContentHeader">
|
||||||
|
<div class="splitSourceTitle">
|
||||||
|
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||||
|
{#if src_loadingBrowse}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else if src_browseResults.length > 0}
|
||||||
|
<span class="splitResultCount">{src_browseResults.length} results</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sourceBrowseBar">
|
||||||
|
<div class="searchBar" style="flex:1">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:value={src_browseQuery}
|
||||||
|
class="searchInput"
|
||||||
|
placeholder="Search {src_activeSource.displayName}…"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
||||||
|
/>
|
||||||
|
{#if src_submitted}
|
||||||
|
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if src_loadingBrowse && src_browseResults.length === 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each Array(18) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if src_browseResults.length > 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each src_browseResults as m, i (m.id)}
|
||||||
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
|
<div class="coverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if src_hasNextPage}
|
||||||
|
<div class="showMoreCell">
|
||||||
|
<button
|
||||||
|
class="showMoreBtn"
|
||||||
|
disabled={src_loadingBrowse}
|
||||||
|
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
|
||||||
|
>
|
||||||
|
{src_loadingBrowse ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !src_loadingBrowse}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="emptyText">No results</p>
|
||||||
|
<p class="emptyHint">Try a different search term.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ctx_source}
|
||||||
|
{@const isPinned = pinnedIds.includes(ctx_source.id)}
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx_x}
|
||||||
|
y={ctx_y}
|
||||||
|
onClose={closeCtx}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isPinned ? "Unpin source" : "Pin source",
|
||||||
|
icon: isPinned ? PushPinSlash : PushPin,
|
||||||
|
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Browse source",
|
||||||
|
icon: ArrowRight,
|
||||||
|
onClick: () => { srcSelectSource(ctx_source!); },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
|
||||||
|
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
|
||||||
|
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
||||||
|
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
|
||||||
|
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||||
|
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||||
|
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); }
|
||||||
|
.splitItemSource { gap: var(--sp-2); }
|
||||||
|
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
|
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||||
|
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
|
||||||
|
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
|
||||||
|
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
||||||
|
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||||
|
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||||
|
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
|
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||||
|
.searchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.clearBtn:hover { color: var(--text-muted); }
|
||||||
|
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); 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); flex-shrink: 0; }
|
||||||
|
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.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); }
|
||||||
|
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
.skTitle { height: 10px; width: 80%; }
|
||||||
|
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, untrack } from "svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
|
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||||
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
|
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import {
|
||||||
|
buildTagFilter,
|
||||||
|
filterSourceCache,
|
||||||
|
COMMON_GENRES,
|
||||||
|
MANGA_STATUSES,
|
||||||
|
type TagMode,
|
||||||
|
type CachedManga,
|
||||||
|
} from "@features/discover/lib/searchFilter";
|
||||||
|
import type { Manga, Source } from "@types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allSources: Source[];
|
||||||
|
sourceCache: Map<number, CachedManga>;
|
||||||
|
sourceCacheReady: boolean;
|
||||||
|
sourceCacheLoading: boolean;
|
||||||
|
sourceCacheEnriching: boolean;
|
||||||
|
onPreview: (m: Manga) => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
allSources, sourceCache,
|
||||||
|
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
|
||||||
|
onPreview,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 200;
|
||||||
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
|
let tag_activeTags: string[] = $state([]);
|
||||||
|
let tag_activeStatuses: string[] = $state([]);
|
||||||
|
let tag_tagMode: TagMode = $state("AND");
|
||||||
|
let tag_tagFilter = $state("");
|
||||||
|
|
||||||
|
const tag_filteredGenres = $derived.by(() => {
|
||||||
|
const q = tag_tagFilter.trim().toLowerCase();
|
||||||
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
|
||||||
|
});
|
||||||
|
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
|
||||||
|
|
||||||
|
let tag_localResults: Manga[] = $state([]);
|
||||||
|
let tag_totalCount = $state(0);
|
||||||
|
let tag_loadingLocal = $state(false);
|
||||||
|
let tag_loadingMoreLocal = $state(false);
|
||||||
|
let tag_localOffset = $state(0);
|
||||||
|
let tag_localHasNext = $state(false);
|
||||||
|
let tag_abortLocal: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _mode = tag_tagMode;
|
||||||
|
const _statuses = tag_activeStatuses;
|
||||||
|
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) {
|
||||||
|
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tag_abortLocal?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_abortLocal = ctrl;
|
||||||
|
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||||
|
tag_loadingLocal = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||||
|
MANGAS_BY_GENRE,
|
||||||
|
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
|
||||||
|
ctrl.signal,
|
||||||
|
).then((d) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
|
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
|
||||||
|
tag_totalCount = d.mangas.totalCount;
|
||||||
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
|
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||||
|
}).catch((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagLoadMoreLocal() {
|
||||||
|
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||||
|
tag_loadingMoreLocal = true;
|
||||||
|
tag_abortLocal?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_abortLocal = ctrl;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||||
|
MANGAS_BY_GENRE,
|
||||||
|
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
|
||||||
|
ctrl.signal,
|
||||||
|
);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
|
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
|
||||||
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
|
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_searchSources = $state(false);
|
||||||
|
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||||
|
|
||||||
|
let tag_sourceFanOut: Manga[] = $state([]);
|
||||||
|
let tag_fanOutLoading = $state(false);
|
||||||
|
let tag_fanOutAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _mode = tag_tagMode;
|
||||||
|
const _statuses = tag_activeStatuses;
|
||||||
|
const _ready = sourceCacheReady;
|
||||||
|
const _search = tag_searchSources;
|
||||||
|
untrack(() => {
|
||||||
|
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
||||||
|
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
|
||||||
|
} else {
|
||||||
|
tag_sourceFiltered = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _search = tag_searchSources;
|
||||||
|
untrack(() => {
|
||||||
|
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
|
||||||
|
tagStartFanOut(_tags[0]);
|
||||||
|
} else {
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
tag_fanOutAbort = null;
|
||||||
|
tag_sourceFanOut = [];
|
||||||
|
tag_fanOutLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tagStartFanOut(genre: string) {
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_fanOutAbort = ctrl;
|
||||||
|
tag_sourceFanOut = [];
|
||||||
|
tag_fanOutLoading = true;
|
||||||
|
|
||||||
|
const seenIds = new Set<number>();
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
const genreLower = genre.toLowerCase();
|
||||||
|
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||||
|
|
||||||
|
await runConcurrent(srcs, async (src) => {
|
||||||
|
for (let page = 1; page <= 2; page++) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
|
||||||
|
let mangas: Manga[];
|
||||||
|
let hasNextPage = false;
|
||||||
|
if (store.searchCache?.has(cacheKey)) {
|
||||||
|
mangas = store.searchCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||||
|
ctrl.signal,
|
||||||
|
).then((d) => d.fetchSourceManga).catch(() => null);
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
mangas = result.mangas;
|
||||||
|
hasNextPage = result.hasNextPage;
|
||||||
|
store.searchCache?.set(cacheKey, mangas);
|
||||||
|
}
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
const matching = mangas.filter((m) =>
|
||||||
|
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
|
||||||
|
);
|
||||||
|
const candidates = (matching.length ? matching : mangas).filter(
|
||||||
|
(m) => !shouldHideNsfw(m, store.settings)
|
||||||
|
);
|
||||||
|
const toAdd: Manga[] = [];
|
||||||
|
for (const m of candidates) {
|
||||||
|
if (seenIds.has(m.id)) continue;
|
||||||
|
const norm = normalizeTitle(m.title);
|
||||||
|
if (seenTitles.has(norm)) continue;
|
||||||
|
seenIds.add(m.id);
|
||||||
|
seenTitles.add(norm);
|
||||||
|
toAdd.push(m);
|
||||||
|
}
|
||||||
|
if (toAdd.length) {
|
||||||
|
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||||
|
}
|
||||||
|
if (!hasNextPage) return;
|
||||||
|
}
|
||||||
|
}, ctrl.signal);
|
||||||
|
|
||||||
|
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_autoSearchFired = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const _tags = tag_activeTags;
|
||||||
|
const _statuses = 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; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||||
|
|
||||||
|
const tag_mergedResults = $derived.by(() => {
|
||||||
|
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
|
||||||
|
const cacheMapped: Manga[] = tag_sourceFiltered
|
||||||
|
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
|
||||||
|
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
||||||
|
return dedupeMangaByTitle(
|
||||||
|
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
||||||
|
store.settings.mangaLinks,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||||
|
|
||||||
|
function tagToggleTag(tag: string) {
|
||||||
|
tag_activeTags = tag_activeTags.includes(tag)
|
||||||
|
? tag_activeTags.filter((t) => t !== tag)
|
||||||
|
: [...tag_activeTags, tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagToggleStatus(status: string) {
|
||||||
|
tag_activeStatuses = tag_activeStatuses.includes(status)
|
||||||
|
? tag_activeStatuses.filter((s) => s !== status)
|
||||||
|
: [...tag_activeStatuses, status];
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
tag_abortLocal?.abort();
|
||||||
|
tag_fanOutAbort?.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splitRoot">
|
||||||
|
|
||||||
|
<div class="splitSidebar">
|
||||||
|
<div class="splitSearchWrap">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
|
||||||
|
{#if tag_tagFilter}
|
||||||
|
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="splitList">
|
||||||
|
<div class="splitSectionLabel">Status</div>
|
||||||
|
{#each MANGA_STATUSES as { value, label } (value)}
|
||||||
|
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
|
||||||
|
<span class="splitItemLabel">{label}</span>
|
||||||
|
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark">✓</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
|
||||||
|
{#each tag_filteredGenres as tag (tag)}
|
||||||
|
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
|
||||||
|
<span class="splitItemLabel">{tag}</span>
|
||||||
|
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if tag_filteredGenres.length === 0}
|
||||||
|
<p class="splitEmpty">No matching genres</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="splitContent">
|
||||||
|
{#if !tag_hasActiveFilters}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">Browse by tag</p>
|
||||||
|
<p class="emptyHint">Select a status or genre to find matching manga.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div class="tagActiveBar">
|
||||||
|
<div class="tagPillRow">
|
||||||
|
{#each tag_activeStatuses as status (status)}
|
||||||
|
<span class="tagPill tagPillStatus">
|
||||||
|
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
|
||||||
|
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#each tag_activeTags as tag (tag)}
|
||||||
|
<span class="tagPill">
|
||||||
|
{tag}
|
||||||
|
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="tagBarRight">
|
||||||
|
{#if tag_activeTags.length > 1}
|
||||||
|
<div class="tagModeToggle">
|
||||||
|
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
|
||||||
|
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="tagModeBtn"
|
||||||
|
class:tagModeBtnActive={tag_searchSources}
|
||||||
|
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
|
||||||
|
disabled={!sourceCacheReady && !sourceCacheLoading}
|
||||||
|
onclick={() => (tag_searchSources = !tag_searchSources)}
|
||||||
|
>
|
||||||
|
{#if sourceCacheLoading || tag_fanOutLoading}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
||||||
|
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Sources{sourceCacheEnriching ? " ·" : ""}
|
||||||
|
</button>
|
||||||
|
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="splitContentHeader">
|
||||||
|
<span class="splitContentTitle">
|
||||||
|
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
|
||||||
|
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
|
||||||
|
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
|
||||||
|
{tag_activeTags[0]}
|
||||||
|
{:else}
|
||||||
|
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
|
||||||
|
{/if}
|
||||||
|
{#if tag_searchSources}
|
||||||
|
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if tag_loadingLocal}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
|
||||||
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="splitResultCount">
|
||||||
|
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
|
||||||
|
{#if tag_searchSources && sourceCacheReady}
|
||||||
|
· {sourceCache.size} cached
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{#if tag_loadingLocal}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each Array(48) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if tag_mergedResults.length > 0}
|
||||||
|
<div class="tagGrid">
|
||||||
|
{#each tag_mergedResults as m, i (m.id)}
|
||||||
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
|
<div class="coverWrap">
|
||||||
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||||
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if tag_loadingMoreLocal}
|
||||||
|
{#each Array(12) as _, i (i)}
|
||||||
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="emptyText">No results</p>
|
||||||
|
<p class="emptyHint">
|
||||||
|
{#if tag_searchSources}Try OR mode or broader tags.
|
||||||
|
{:else}Try OR mode, enable Sources, or check your library.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
|
||||||
|
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||||
|
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||||
|
.splitSearchClear:hover { color: var(--text-muted); }
|
||||||
|
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||||
|
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
|
||||||
|
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.splitItemActive:hover { background: var(--accent-muted); }
|
||||||
|
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
|
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||||
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||||
|
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||||
|
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||||
|
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
|
||||||
|
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||||
|
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
|
||||||
|
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||||
|
.tagPillRemove:hover { opacity: 1; }
|
||||||
|
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
|
||||||
|
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.tagModeBtn:last-child { border-right: none; }
|
||||||
|
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.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; }
|
||||||
|
.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); }
|
||||||
|
.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 } }
|
||||||
|
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||||
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
|
.skTitle { height: 10px; width: 80%; }
|
||||||
|
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||||
|
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||||
|
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||||
|
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||||
|
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Search } from "./components/Search.svelte";
|
||||||
|
export * from "./lib/searchFilter";
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { Settings } from "@types";
|
||||||
|
import { shouldHideNsfw } from "@core/util";
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 50;
|
||||||
|
export const INITIAL_PAGES = 3;
|
||||||
|
export const MAX_SOURCES = 12;
|
||||||
|
export const CONCURRENCY = 4;
|
||||||
|
|
||||||
|
export function parseTags(f: string): string[] {
|
||||||
|
return f.split("+").map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagsLabel(tags: string[]): string {
|
||||||
|
if (tags.length === 1) return tags[0];
|
||||||
|
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesAllTags(m: { genre?: string[] }, tags: string[]): boolean {
|
||||||
|
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
||||||
|
return tags.every((t) => g.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let i = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (i < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
await fn(items[i++]).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagMode = "AND" | "OR";
|
||||||
|
|
||||||
|
export interface CachedManga {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
inLibrary: boolean;
|
||||||
|
status: string;
|
||||||
|
genre: string[];
|
||||||
|
lowerGenres: string[];
|
||||||
|
sourceId: string;
|
||||||
|
genreEnriched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMMON_GENRES = [
|
||||||
|
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||||
|
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
||||||
|
"Supernatural", "Mecha", "Historical", "Psychological", "School Life",
|
||||||
|
"Shounen", "Seinen", "Josei", "Shoujo", "Isekai", "Martial Arts",
|
||||||
|
"Magic", "Music", "Cooking", "Medical", "Military", "Harem", "Ecchi",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MANGA_STATUSES: { value: string; label: string }[] = [
|
||||||
|
{ value: "ONGOING", label: "Ongoing" },
|
||||||
|
{ value: "COMPLETED", label: "Completed" },
|
||||||
|
{ value: "HIATUS", label: "Hiatus" },
|
||||||
|
{ value: "ABANDONED", label: "Abandoned" },
|
||||||
|
{ value: "UNKNOWN", label: "Unknown" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildTagFilter(
|
||||||
|
tags: string[],
|
||||||
|
mode: TagMode,
|
||||||
|
statuses: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const genrePart: Record<string, unknown> | null =
|
||||||
|
tags.length === 0 ? null :
|
||||||
|
mode === "AND"
|
||||||
|
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
|
||||||
|
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
|
||||||
|
|
||||||
|
const statusPart: Record<string, unknown> | null =
|
||||||
|
statuses.length === 0 ? null :
|
||||||
|
statuses.length === 1
|
||||||
|
? { status: { equalTo: statuses[0] } }
|
||||||
|
: { or: statuses.map((s) => ({ status: { equalTo: s } })) };
|
||||||
|
|
||||||
|
if (!genrePart && !statusPart) return {};
|
||||||
|
if (genrePart && !statusPart) return genrePart;
|
||||||
|
if (!genrePart && statusPart) return statusPart;
|
||||||
|
return { and: [genrePart, statusPart] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSourceCache(
|
||||||
|
sourceCache: Map<number, CachedManga>,
|
||||||
|
tags: string[],
|
||||||
|
mode: TagMode,
|
||||||
|
statuses: string[],
|
||||||
|
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
|
): CachedManga[] {
|
||||||
|
return [...sourceCache.values()].filter((m) => {
|
||||||
|
if (shouldHideNsfw(m as any, settings)) return false;
|
||||||
|
|
||||||
|
const statusMatch =
|
||||||
|
statuses.length === 0 || statuses.includes(m.status);
|
||||||
|
|
||||||
|
let genreMatch = true;
|
||||||
|
if (tags.length > 0) {
|
||||||
|
const lower = m.lowerGenres;
|
||||||
|
if (mode === "AND") {
|
||||||
|
genreMatch = tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
||||||
|
} else {
|
||||||
|
genreMatch = tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusMatch && genreMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCachedManga(
|
||||||
|
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||||
|
srcId: string,
|
||||||
|
): CachedManga {
|
||||||
|
const genre = m.genre ?? [];
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
thumbnailUrl: m.thumbnailUrl,
|
||||||
|
inLibrary: m.inLibrary,
|
||||||
|
status: m.status ?? "UNKNOWN",
|
||||||
|
genre,
|
||||||
|
lowerGenres: genre.map((g) => g.toLowerCase()),
|
||||||
|
sourceId: srcId,
|
||||||
|
genreEnriched: genre.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
|
import { pageProgress } from "../lib/downloadQueue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: DownloadQueueItem;
|
||||||
|
isActive: boolean;
|
||||||
|
isRemoving: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
onRemove: (chapterId: number) => void;
|
||||||
|
onRetry: (chapterId: number) => void;
|
||||||
|
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
item, isActive, isRemoving, isSelected,
|
||||||
|
onRemove, onRetry, onSelect,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const manga = $derived(item.chapter.manga);
|
||||||
|
const pages = $derived(item.chapter.pageCount ?? 0);
|
||||||
|
const prog = $derived(pageProgress(item.progress, pages));
|
||||||
|
const isError = $derived(item.state === "ERROR");
|
||||||
|
const pct = $derived(Math.round(item.progress * 100));
|
||||||
|
|
||||||
|
function rowLongPress(node: HTMLElement) {
|
||||||
|
return longPress(node, {
|
||||||
|
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
class:row-active={isActive}
|
||||||
|
class:row-error={isError}
|
||||||
|
class:row-selected={isSelected}
|
||||||
|
class:row-removing={isRemoving}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
tabindex="0"
|
||||||
|
use:rowLongPress
|
||||||
|
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||||
|
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
|
||||||
|
>
|
||||||
|
{#if manga?.thumbnailUrl}
|
||||||
|
<div class="thumb">
|
||||||
|
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="thumb-img" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
||||||
|
<span class="chapter-name">{item.chapter.name}</span>
|
||||||
|
{#if pages > 0}
|
||||||
|
<div class="progress-row">
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" class:progress-error={isError} style="width:{pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="pages-label">
|
||||||
|
{#if isActive}
|
||||||
|
{prog.done}/{prog.total}
|
||||||
|
{:else if isError}
|
||||||
|
failed · {item.tries} {item.tries === 1 ? "try" : "tries"}
|
||||||
|
{:else}
|
||||||
|
{prog.total}p
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-right">
|
||||||
|
<span class="state-label" class:state-error={isError}>{item.state}</span>
|
||||||
|
<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}
|
||||||
|
</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}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
|
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||||
|
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
|
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
|
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
|
|
||||||
|
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
|
||||||
|
.row-active .progress-bar { opacity: 1; }
|
||||||
|
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
||||||
|
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||||
|
|
||||||
|
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||||
|
|
||||||
|
.actions { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||||
|
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch } from "phosphor-svelte";
|
||||||
|
import DownloadItem from "./DownloadItem.svelte";
|
||||||
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
queue: DownloadQueueItem[];
|
||||||
|
loading: boolean;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
queue, loading, isRunning, dequeueing, selected,
|
||||||
|
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="list">
|
||||||
|
{#each Array(5) as _, i (i)}
|
||||||
|
<div class="sk-row">
|
||||||
|
<div class="sk-thumb skeleton"></div>
|
||||||
|
|
||||||
|
<div class="sk-info">
|
||||||
|
<div class="skeleton sk-title"></div>
|
||||||
|
<div class="skeleton sk-chapter"></div>
|
||||||
|
<div class="sk-progress-row">
|
||||||
|
<div class="skeleton sk-bar"></div>
|
||||||
|
<div class="skeleton sk-pages"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-right">
|
||||||
|
<div class="skeleton sk-state"></div>
|
||||||
|
<div class="sk-actions">
|
||||||
|
<div class="skeleton sk-btn"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if queue.length === 0}
|
||||||
|
<div class="empty">Queue is empty.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="list">
|
||||||
|
{#each queue as item, i (item.chapter.id)}
|
||||||
|
<DownloadItem
|
||||||
|
{item}
|
||||||
|
isActive={i === 0 && isRunning}
|
||||||
|
isRemoving={dequeueing.has(item.chapter.id)}
|
||||||
|
isSelected={selected.has(item.chapter.id)}
|
||||||
|
{onRemove}
|
||||||
|
{onRetry}
|
||||||
|
{onReorder}
|
||||||
|
{onReorderEdge}
|
||||||
|
{onSelect}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||||
|
.skeleton {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||||
|
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
|
||||||
|
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
|
||||||
|
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
|
||||||
|
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
|
||||||
|
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
|
||||||
|
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.sk-bar { flex: 1; height: 2px; }
|
||||||
|
.sk-pages { width: 28px; height: 9px; }
|
||||||
|
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
.sk-state { width: 54px; height: 9px; }
|
||||||
|
.sk-actions { display: flex; gap: 2px; }
|
||||||
|
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
|
||||||
|
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
|
||||||
|
import DownloadQueue from "./DownloadQueue.svelte";
|
||||||
|
import { downloadStore } from "../store/downloadState.svelte";
|
||||||
|
import { formatEta } from "../lib/downloadQueue";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
downloadStore.poll();
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectAnchor = $state<number | null>(null);
|
||||||
|
let moveBy = $state(1);
|
||||||
|
|
||||||
|
const selectedErrorCount = $derived(
|
||||||
|
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
if (e.shiftKey && selectAnchor !== null) {
|
||||||
|
downloadStore.selectRange(selectAnchor, chapterId);
|
||||||
|
} else if (ctrl) {
|
||||||
|
downloadStore.toggleSelect(chapterId);
|
||||||
|
selectAnchor = chapterId;
|
||||||
|
} else {
|
||||||
|
if (downloadStore.selected.size > 1) {
|
||||||
|
downloadStore.toggleSelect(chapterId);
|
||||||
|
selectAnchor = chapterId;
|
||||||
|
} else if (downloadStore.selected.size === 1 && downloadStore.selected.has(chapterId)) {
|
||||||
|
downloadStore.clearSelection();
|
||||||
|
selectAnchor = null;
|
||||||
|
} else {
|
||||||
|
downloadStore.selectOnly(chapterId);
|
||||||
|
selectAnchor = chapterId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOff() {
|
||||||
|
if (downloadStore.selected.size > 0) {
|
||||||
|
downloadStore.clearSelection();
|
||||||
|
selectAnchor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
downloadStore.clearSelection();
|
||||||
|
selectAnchor = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="heading">Downloads</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={downloadStore.autoRetryEnabled}
|
||||||
|
onclick={() => downloadStore.toggleAutoRetry()}
|
||||||
|
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
|
||||||
|
>
|
||||||
|
<Repeat size={14} weight="regular" />
|
||||||
|
</button>
|
||||||
|
{#if downloadStore.hasErrored}
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={() => downloadStore.retryAllErrored()}
|
||||||
|
disabled={downloadStore.batchWorking}
|
||||||
|
title="Retry all errored"
|
||||||
|
>
|
||||||
|
{#if downloadStore.batchWorking}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<ArrowClockwise size={14} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={downloadStore.toastsEnabled}
|
||||||
|
onclick={() => downloadStore.toggleToasts()}
|
||||||
|
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
|
||||||
|
>
|
||||||
|
{#if downloadStore.toastsEnabled}
|
||||||
|
<Bell size={14} weight="regular" />
|
||||||
|
{:else}
|
||||||
|
<BellSlash size={14} weight="regular" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:loading={downloadStore.togglingPlay}
|
||||||
|
onclick={() => downloadStore.togglePlay()}
|
||||||
|
disabled={downloadStore.togglingPlay || (downloadStore.queue.length === 0 && !downloadStore.isRunning)}
|
||||||
|
title={downloadStore.isRunning ? "Pause" : "Resume"}
|
||||||
|
>
|
||||||
|
{#if downloadStore.togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if downloadStore.isRunning}<Pause size={14} weight="fill" />
|
||||||
|
{:else}<Play size={14} weight="fill" />{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:loading={downloadStore.clearing}
|
||||||
|
onclick={() => downloadStore.clear()}
|
||||||
|
disabled={downloadStore.clearing || downloadStore.queue.length === 0}
|
||||||
|
title="Clear queue"
|
||||||
|
>
|
||||||
|
{#if downloadStore.clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}<Trash size={14} weight="regular" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bar-wrap">
|
||||||
|
<div class="status-bar" role="none">
|
||||||
|
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||||
|
<span class="status-text">
|
||||||
|
{downloadStore.togglingPlay
|
||||||
|
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
||||||
|
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
|
{#if downloadStore.selected.size > 0}
|
||||||
|
<div class="sel-controls">
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
||||||
|
<ArrowLineUp size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
||||||
|
<CaretUp size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
class="move-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={moveBy}
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
|
||||||
|
<CaretDown size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
|
||||||
|
<ArrowLineDown size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{#if selectedErrorCount > 0}
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
|
||||||
|
<ArrowClockwise size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
<span class="status-count">{downloadStore.selected.size} selected</span>
|
||||||
|
{:else}
|
||||||
|
<div class="status-right">
|
||||||
|
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||||
|
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||||
|
{/if}
|
||||||
|
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
|
||||||
|
<DownloadQueue
|
||||||
|
queue={downloadStore.queue}
|
||||||
|
loading={downloadStore.loading}
|
||||||
|
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)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.header-actions { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||||
|
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||||
|
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||||
|
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
|
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
|
||||||
|
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.status-bar { cursor: default; }
|
||||||
|
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
|
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||||
|
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
|
||||||
|
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||||
|
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.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::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
|
.move-input:focus { color: var(--text-primary); }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { downloadStore } from "./store/downloadState.svelte";
|
||||||
|
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
|
|
||||||
|
const RETRY_DELAY_MS = 20_000;
|
||||||
|
|
||||||
|
export interface AutoRetryHandle {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAutoRetry(
|
||||||
|
getQueue: () => DownloadQueueItem[],
|
||||||
|
isRunning: () => boolean,
|
||||||
|
retryErrored: () => Promise<void>,
|
||||||
|
): AutoRetryHandle {
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
const queue = getQueue();
|
||||||
|
const errored = queue.filter(i => i.state === "ERROR");
|
||||||
|
const active = queue.filter(i => i.state !== "ERROR");
|
||||||
|
|
||||||
|
if (errored.length > 0 && active.length === 0 && !isRunning()) {
|
||||||
|
await retryErrored().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user