Compare commits
234 Commits
v0.4.0
...
93cedca6b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| 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 |
@@ -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"
|
||||||
@@ -4,9 +4,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.3.0)"
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
@@ -40,9 +43,6 @@ jobs:
|
|||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
download_suwayomi() {
|
download_suwayomi() {
|
||||||
local asset="$1" sha="$2" outdir="$3"
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||||
-o "${outdir}.tar.gz"
|
-o "${outdir}.tar.gz"
|
||||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
mkdir -p "${outdir}"
|
mkdir -p "${outdir}"
|
||||||
@@ -87,162 +87,108 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
download_suwayomi \
|
download_suwayomi \
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||||
"suwayomi-arm64"
|
"suwayomi-arm64"
|
||||||
|
|
||||||
download_suwayomi \
|
download_suwayomi \
|
||||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||||
"suwayomi-x64"
|
"suwayomi-x64"
|
||||||
|
|
||||||
- name: Stage Suwayomi sidecars
|
- name: Stage Suwayomi sidecars
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
find_launcher() {
|
stage_arch() {
|
||||||
local dir="$1"
|
local srcdir="$1"
|
||||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
local arch="$2"
|
||||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
|
||||||
echo "ERROR: could not find launchers — tarball contents:"
|
|
||||||
ls -lR suwayomi-arm64 suwayomi-x64
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "arm64 launcher: $ARM_LAUNCHER"
|
|
||||||
echo "x64 launcher: $X64_LAUNCHER"
|
|
||||||
|
|
||||||
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
|
||||||
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
|
||||||
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
|
||||||
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
|
||||||
|
|
||||||
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
|
|
||||||
# We stage both arch bundles and swap the symlink before each build.
|
|
||||||
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
|
|
||||||
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
# dist/ is already built by the frontend job — suppress the rebuild.
|
|
||||||
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
- name: Swap bundle for aarch64
|
- name: Swap bundle for aarch64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-arm64 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)
|
- name: Build Tauri app (aarch64)
|
||||||
uses: tauri-apps/tauri-action@v0
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
|
||||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
with:
|
|
||||||
args: --target aarch64-apple-darwin
|
|
||||||
|
|
||||||
- name: Swap bundle for x86_64
|
- name: Swap bundle for x86_64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x64 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)
|
- name: Build Tauri app (x86_64)
|
||||||
uses: tauri-apps/tauri-action@v0
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
|
||||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
with:
|
|
||||||
args: --target x86_64-apple-darwin
|
|
||||||
|
|
||||||
- name: Upload arm64 .dmg
|
- name: Upload macOS artifacts to release
|
||||||
uses: actions/upload-artifact@v4
|
env:
|
||||||
with:
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: moku-aarch64
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: moku-x86_64
|
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload arm64 .app (for universal job)
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-aarch64-apple-darwin
|
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Upload x64 .app (for universal job)
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-x86_64-apple-darwin
|
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
universal:
|
|
||||||
name: Universal .dmg
|
|
||||||
needs: tauri
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download arm64 .app
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-aarch64-apple-darwin
|
|
||||||
path: apps/arm64/
|
|
||||||
|
|
||||||
- name: Download x64 .app
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: app-x86_64-apple-darwin
|
|
||||||
path: apps/x64/
|
|
||||||
|
|
||||||
- name: lipo into universal binary
|
|
||||||
run: |
|
run: |
|
||||||
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
# Wait for the Windows workflow to have created the draft release
|
||||||
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
for i in $(seq 1 12); do
|
||||||
APP_NAME=$(basename "$ARM_APP")
|
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
|
||||||
mkdir -p universal
|
echo "Waiting for release to exist... attempt $i"
|
||||||
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
sleep 15
|
||||||
|
|
||||||
find "universal/${APP_NAME}" -type f | while read -r f; do
|
|
||||||
if file "$f" | grep -q "Mach-O"; then
|
|
||||||
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
|
||||||
if [ -f "$X64_EQUIV" ]; then
|
|
||||||
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Package universal .dmg
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
run: |
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
exit 1
|
||||||
mkdir dmg-stage
|
fi
|
||||||
cp -r "universal/${APP_NAME}" dmg-stage/
|
|
||||||
ln -s /Applications dmg-stage/Applications
|
|
||||||
hdiutil create \
|
|
||||||
-volname "Moku" \
|
|
||||||
-srcfolder dmg-stage \
|
|
||||||
-ov -format UDZO \
|
|
||||||
"moku-universal.dmg"
|
|
||||||
|
|
||||||
- name: Upload universal .dmg
|
echo "Found release ID: $RELEASE_ID"
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
upload_asset() {
|
||||||
name: moku-universal
|
local file="$1"
|
||||||
path: moku-universal.dmg
|
local name="$2"
|
||||||
retention-days: 7
|
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"
|
||||||
@@ -4,9 +4,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
@@ -76,9 +79,9 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
- name: Extract Suwayomi bundle
|
||||||
@@ -93,8 +96,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
echo "Extracted bundle contents (top-level):"
|
|
||||||
ls -la suwayomi-extracted/
|
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -103,17 +104,15 @@ jobs:
|
|||||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
if [ -z "$JAVA" ]; then
|
if [ -z "$JAVA" ]; then
|
||||||
echo "ERROR: jre/bin/java.exe not found. Bundle contents:"
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
find suwayomi-extracted -type f | head -50
|
find suwayomi-extracted -type f | head -50
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -z "$JAR" ]; then
|
if [ -z "$JAR" ]; then
|
||||||
echo "ERROR: Suwayomi-Server.jar not found. Bundle contents:"
|
echo "ERROR: Suwayomi-Server.jar not found"
|
||||||
find suwayomi-extracted -type f | head -50
|
find suwayomi-extracted -type f | head -50
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Found java: $JAVA"
|
|
||||||
echo "Found jar: $JAR"
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Validate staging
|
- name: Validate staging
|
||||||
@@ -129,19 +128,40 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
echo "tauri.conf.json patched:"
|
|
||||||
cat src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Build Tauri app (Windows x64)
|
- 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
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
tagName: v${{ github.event.inputs.version }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
- name: Upload Windows installer
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
uses: actions/upload-artifact@v4
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
with:
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
name: moku-windows-x64
|
**Linux:** Download `moku.flatpak`
|
||||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
releaseDraft: true
|
||||||
retention-days: 7
|
prerelease: false
|
||||||
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -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.4.0
|
pkgver=0.9.3
|
||||||
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'
|
||||||
@@ -18,13 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
"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"
|
)
|
||||||
|
sha256sums=(
|
||||||
|
'4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684'
|
||||||
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -34,7 +34,6 @@ prepare() {
|
|||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
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
|
||||||
@@ -46,17 +45,14 @@ package() {
|
|||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -64,11 +60,11 @@ server.autoDownloadNewChapters = false
|
|||||||
server.globalUpdateInterval = 12
|
server.globalUpdateInterval = 12
|
||||||
server.maxSourcesInParallel = 6
|
server.maxSourcesInParallel = 6
|
||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
CONF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/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
|
||||||
@@ -90,25 +86,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
|
||||||
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.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 "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,140 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/moku-icon-rounded.svg" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and Svelte.</p>
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
|

|
||||||
|
[](https://github.com/moku-project/Moku)
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||||
|
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||||
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||||
|
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Features
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
|
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
|
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||||
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Nix (recommended)**
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**winget:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Moku.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
|
|
||||||
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install io.github.moku_app.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install moku.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### macOS
|
||||||
|
|
||||||
```bash
|
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
nix build
|
> ```bash
|
||||||
./result/bin/moku
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
```
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/moku-project/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -54,12 +148,20 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
|
|||||||
@@ -1,104 +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" & more GPU Issues
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
|
Minor Revisions:
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
|
||||||
Bugs:
|
Priority Bugs:
|
||||||
|
- Fix Library-Refresh System (TESTING)
|
||||||
|
|
||||||
- Add Back after Search & Clear on Search
|
- Suwayomi RESET
|
||||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
|
Pending/On-Hold:
|
||||||
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
- Change Auto-Link Threshold
|
||||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
- Fix Auto-Link De-dupe for Images
|
||||||
skip chapters, etc
|
- Optimize Auto-Link Latency (IP)
|
||||||
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
|
||||||
- Reset to top when user clicks next chapter in reader.
|
|
||||||
|
|
||||||
|
In-Progress:
|
||||||
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
- Fix Downloaded in Library (Tags Broken) & All
|
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||||
- Using Delete All Crashes App (But Works)
|
- Note User's have to always install extensions manually
|
||||||
- Fix Folder Display in Library
|
- Create "Missing Source" for Manga
|
||||||
- Add Version Tags (To Find Version)
|
|
||||||
- Sidebar Icon Highlighted
|
|
||||||
- Introduce Deduplication into Library & Search
|
|
||||||
|
|
||||||
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
|
|
||||||
Features:
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
- Add PDF Textbook Support
|
Notes from last time:
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
- Add Consumet Api (Anime & Light Novel Support)
|
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
|
||||||
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:
|
|
||||||
|
|
||||||
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
|
||||||
- Fix the Mark as Read (Glitched)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
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
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
|
||||||
- Extensions Page no Longer Loading efficiently
|
|
||||||
- Map out MangaPreview tags to GenreDrill
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
|
||||||
- Clean up Migrate Model to be more initutive
|
|
||||||
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 Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Fix scaling on splash screen
|
|
||||||
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
|
||||||
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#52b888"/>
|
||||||
|
<stop offset="100%" stop-color="#1e5840"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="roundedBounds">
|
||||||
|
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clip-path="url(#roundedBounds)">
|
||||||
|
|
||||||
|
<rect width="1280" height="320" fill="#070e09"/>
|
||||||
|
|
||||||
|
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||||
|
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||||
|
fill="url(#leafHero)" opacity="0.97">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Stack text pinned to bottom -->
|
||||||
|
<text
|
||||||
|
x="640" y="300"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
font-size="14"
|
||||||
|
letter-spacing="5"
|
||||||
|
fill="#a8c4a8"
|
||||||
|
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -2,11 +2,11 @@
|
|||||||
description = "Moku — manga reader frontend for Suwayomi";
|
description = "Moku — manga reader frontend for Suwayomi";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
crane.url = "github:ipetkov/crane";
|
crane.url = "github:ipetkov/crane";
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -14,11 +14,15 @@
|
|||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem =
|
||||||
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.4.0";
|
version = "0.9.3";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -26,7 +30,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [ "rust-src" "rust-analyzer" ];
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
@@ -48,8 +55,10 @@
|
|||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
frontendSrc = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter = path: type:
|
filter =
|
||||||
let base = builtins.baseNameOf path;
|
path: type:
|
||||||
|
let
|
||||||
|
base = builtins.baseNameOf path;
|
||||||
in
|
in
|
||||||
(lib.hasInfix "/src" path)
|
(lib.hasInfix "/src" path)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
@@ -59,223 +68,44 @@
|
|||||||
|| base == "vite.config.ts";
|
|| base == "vite.config.ts";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-FsZTHeBS9qQ9KYgiwDX1vam6uJXK8OjLe5U6Jfu33lc=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
cargoSrc = lib.cleanSourceWith {
|
||||||
src = ./src-tauri;
|
src = ./src-tauri;
|
||||||
filter = path: type:
|
filter =
|
||||||
|
path: type:
|
||||||
(craneLib.filterCargoSources path type)
|
(craneLib.filterCargoSources path type)
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|| (lib.hasInfix "/icons/" path)
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|| (lib.hasInfix "/capabilities/" path)
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||||
};
|
};
|
||||||
|
|
||||||
commonArgs = {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||||
src = cargoSrc;
|
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
inherit version;
|
||||||
strictDeps = true;
|
src = frontendSrc;
|
||||||
buildInputs = runtimeLibs;
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
|
||||||
preBuild = ''
|
|
||||||
cp -r ${frontend} ../dist
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
moku = import ./nix/moku.nix {
|
||||||
|
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
appIcon = ./src/assets/moku-icon.svg;
|
||||||
inherit cargoArtifacts;
|
|
||||||
meta.mainProgram = "moku";
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p "$out/share/applications"
|
|
||||||
cat > "$out/share/applications/moku.desktop" << EOF
|
|
||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Moku
|
|
||||||
Comment=Manga reader frontend for Suwayomi
|
|
||||||
Exec=$out/bin/moku
|
|
||||||
Icon=moku
|
|
||||||
Terminal=false
|
|
||||||
Categories=Graphics;Viewer;
|
|
||||||
Keywords=manga;comic;reader;suwayomi;
|
|
||||||
StartupWMClass=moku
|
|
||||||
EOF
|
|
||||||
|
|
||||||
for size in 32x32 128x128 256x256 512x512; do
|
|
||||||
src="icons/$size.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
for size in 128x128 256x256; do
|
|
||||||
src="icons/''${size}@2x.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
|
||||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
|
||||||
|
|
||||||
wrapProgram $out/bin/moku \
|
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
|
||||||
pkgs.gsettings-desktop-schemas
|
|
||||||
pkgs.gtk3
|
|
||||||
]}" \
|
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
|
||||||
--set GDK_BACKEND wayland \
|
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Bumped to $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
flatpakScript = pkgs.writeShellApplication {
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||||
name = "moku-flatpak";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
gnused coreutils git
|
|
||||||
nodejs_22 pnpm
|
|
||||||
appstream flatpak-builder flatpak
|
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
|
||||||
];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/dev.moku.app.yml"
|
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
|
||||||
cd "$REPO"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO/dist" .
|
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
|
||||||
echo "sha256: $FRONTEND_SHA"
|
|
||||||
|
|
||||||
echo "── Patching manifest sha256 ──"
|
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
|
||||||
import re, sys
|
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
|
||||||
text = open(path).read()
|
|
||||||
updated, n = re.subn(
|
|
||||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
|
||||||
r'\g<1>' + sha, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building flatpak ──"
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="$REPO/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"$REPO/build-dir" \
|
|
||||||
"$MANIFEST"
|
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" dev.moku.app
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
echo "moku.flatpak created"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done — v$VERSION"
|
|
||||||
echo " -> $REPO/moku.flatpak"
|
|
||||||
echo ""
|
|
||||||
echo "After pushing the tag, run:"
|
|
||||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-pkgbuild-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
|
||||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
|
||||||
|
|
||||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
|
||||||
echo "Fetching tarball sha256..."
|
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
|
||||||
|
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
|
||||||
|
|
||||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
apps = {
|
packages = {
|
||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
inherit moku frontend suwayomiServer;
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
default = moku;
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = {
|
apps = {
|
||||||
inherit moku frontend;
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
default = moku;
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
|
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
|
||||||
|
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||||
|
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -286,20 +116,27 @@ EOF
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomiServer
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [
|
||||||
|
ps.aiohttp
|
||||||
|
ps.tomlkit
|
||||||
|
]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release:"
|
echo " nix run .#bump -- <ver>"
|
||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " nix run .#post-tag-bump -- <ver>"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#flatpak -- <ver>"
|
||||||
|
echo " nix run .#tunnel -- [port]"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
app-id: io.github.moku_project.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -33,13 +39,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -92,12 +95,12 @@ modules:
|
|||||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -111,7 +114,7 @@ modules:
|
|||||||
cat > /app/bin/tachidesk-server << 'EOF'
|
cat > /app/bin/tachidesk-server << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# Seed conf on first run
|
# Seed conf on first run
|
||||||
@@ -120,7 +123,6 @@ modules:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
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|' \
|
||||||
@@ -128,7 +130,7 @@ modules:
|
|||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
# Append keys if absent
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
@@ -138,8 +140,6 @@ modules:
|
|||||||
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"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -155,8 +155,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
||||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -171,17 +171,19 @@ modules:
|
|||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/moku-project/Moku.git
|
||||||
|
tag: v0.9.3
|
||||||
|
commit: 9f8bf6ffc11e0808acc735132e1aeff8b3bf1e09
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
sha256: c690eb3cb24e89fec3f4e92f7a4a82d9a465b58f6680a332c1e44f1361ac96af
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
|
||||||
|
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
||||||
|
|
||||||
|
pnpmDeps = fetchPnpmDeps {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
fetcherVersion = 1;
|
||||||
|
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = "pnpm build";
|
||||||
|
installPhase = "cp -r dist $out";
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
craneLib,
|
||||||
|
pkgs,
|
||||||
|
runtimeLibs,
|
||||||
|
frontend,
|
||||||
|
suwayomiServer,
|
||||||
|
version,
|
||||||
|
cargoSrc,
|
||||||
|
appIcon,
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
commonArgs = {
|
||||||
|
src = cargoSrc;
|
||||||
|
pname = "moku";
|
||||||
|
inherit version;
|
||||||
|
strictDeps = true;
|
||||||
|
buildInputs = runtimeLibs;
|
||||||
|
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||||
|
preBuild = ''
|
||||||
|
cp -r ${frontend} ../dist
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
in
|
||||||
|
craneLib.buildPackage (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
|
||||||
|
meta.mainProgram = "moku";
|
||||||
|
|
||||||
|
postInstall = ''
|
||||||
|
mkdir -p "$out/share/applications"
|
||||||
|
cat > "$out/share/applications/moku.desktop" << EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Moku
|
||||||
|
Comment=Manga reader frontend for Suwayomi
|
||||||
|
Exec=$out/bin/moku
|
||||||
|
Icon=moku
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Viewer;
|
||||||
|
Keywords=manga;comic;reader;suwayomi;
|
||||||
|
StartupWMClass=moku
|
||||||
|
EOF
|
||||||
|
|
||||||
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
|
src="icons/$size.png"
|
||||||
|
[ -f "$src" ] && install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
for size in 128x128 256x256; do
|
||||||
|
src="icons/''${size}@2x.png"
|
||||||
|
[ -f "$src" ] && install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
install -Dm644 "${appIcon}" \
|
||||||
|
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||||
|
|
||||||
|
wrapProgram $out/bin/moku \
|
||||||
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
|
pkgs.gsettings-desktop-schemas
|
||||||
|
pkgs.gtk3
|
||||||
|
]}" \
|
||||||
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
|
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
|
||||||
|
--set GDK_BACKEND wayland \
|
||||||
|
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
|
'';
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{ pkgs, rustToolchain, version }:
|
||||||
|
|
||||||
|
{
|
||||||
|
bump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
echo "── Bumping version fields to $VERSION ──"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating Cargo.lock ──"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building frontend ──"
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Patching flatpak manifest ──"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
postTagBump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-post-tag-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
echo "── Resolving commit for v$VERSION ──"
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
||||||
|
| awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
echo "commit: $COMMIT"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "post-tag-bump complete for v$VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpak = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$MANIFEST"
|
||||||
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
|
||||||
|
echo "moku.flatpak created"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
tunnel = pkgs.writeShellApplication {
|
||||||
|
name = "moku-tunnel";
|
||||||
|
runtimeInputs = with pkgs; [ cloudflared ];
|
||||||
|
text = ''
|
||||||
|
PORT="''${1:-4567}"
|
||||||
|
cloudflared tunnel --url "http://localhost:$PORT"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
fetchurl,
|
||||||
|
makeWrapper,
|
||||||
|
jdk21_headless,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
jdk = jdk21_headless;
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
pname = "suwayomi-server";
|
||||||
|
version = "2.1.2087";
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
|
||||||
|
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
dontUnpack = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
|
||||||
|
|
||||||
|
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
|
||||||
|
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
|
||||||
|
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
||||||
|
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
||||||
|
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
||||||
|
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${finalAttrs.version}";
|
||||||
|
license = lib.licenses.mpl20;
|
||||||
|
platforms = jdk.meta.platforms;
|
||||||
|
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||||
|
mainProgram = "suwayomi-server";
|
||||||
|
};
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,17 +10,23 @@
|
|||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@tauri-apps/plugin-store": "~2.4.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"svelte-spa-router": "^5.1.0",
|
||||||
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.11.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^3.0.0",
|
"svelte-check": "^4.4.7",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^5.0.0"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>dev.moku.app</id>
|
|
||||||
<metadata_license>MIT</metadata_license>
|
|
||||||
<project_license>MIT</project_license>
|
|
||||||
|
|
||||||
<name>Moku</name>
|
|
||||||
<summary>Manga reader powered by Suwayomi</summary>
|
|
||||||
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
|
||||||
providing a clean native interface for browsing, reading, and managing your
|
|
||||||
manga library across hundreds of sources.
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
|
||||||
|
|
||||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
|
||||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
|
||||||
|
|
||||||
<provides>
|
|
||||||
<binary>moku</binary>
|
|
||||||
</provides>
|
|
||||||
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
|
|
||||||
<releases>
|
|
||||||
<release version="0.4.0" date="2025-03-22">
|
|
||||||
<description>
|
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
</component>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.moku_project.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.9.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Old release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.4.0"
|
version = "0.9.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,17 +15,32 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
tauri-plugin-process = "2"
|
||||||
serde_json = "1"
|
tauri-plugin-http = "2"
|
||||||
walkdir = "2"
|
tauri-plugin-dialog = "2"
|
||||||
sysinfo = "0.32"
|
tauri-plugin-os = "2.3.2"
|
||||||
dirs = "5"
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
walkdir = "2"
|
||||||
|
sysinfo = "0.32"
|
||||||
|
dirs = "5"
|
||||||
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Security_Credentials_UI",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# — Suwayomi launcher for Linux AppImage/deb.
|
||||||
|
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||||
|
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||||
|
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||||
|
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||||
|
# We resolve relative to this script's own location.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# Tauri places resources one level up from the binary on Linux.
|
||||||
|
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||||
|
find_resource() {
|
||||||
|
for candidate in \
|
||||||
|
"${SCRIPT_DIR}" \
|
||||||
|
"${SCRIPT_DIR}/../resources" \
|
||||||
|
"${SCRIPT_DIR}/resources"
|
||||||
|
do
|
||||||
|
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DIR=$(find_resource) || {
|
||||||
|
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||||
|
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||||
|
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||||
|
|
||||||
|
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||||
|
echo "[launcher] JAVA=$JAVA" >&2
|
||||||
|
echo "[launcher] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||||
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
|
server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = true
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "PREVIEW"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
|
sed -i \
|
||||||
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||||
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||||
|
unset DISPLAY
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
|
||||||
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
|
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||||
|
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||||
|
# bring down the whole server process (mirrors the Flatpak build).
|
||||||
|
if [ -f "$CATCH_ABORT" ]; then
|
||||||
|
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dapple.awt.UIElement=true \
|
||||||
|
-Dsun.java2d.noddraw=true \
|
||||||
|
-Dsun.awt.disablegui=true \
|
||||||
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Moku — Suwayomi launcher sidecar for macOS.
|
||||||
|
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||||
|
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Resolve the real directory of this script, following symlinks.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||||
|
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||||
|
# bundle = Contents/Resources/suwayomi-bundle/
|
||||||
|
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||||
|
find_bundle() {
|
||||||
|
local base="$1"
|
||||||
|
for candidate in \
|
||||||
|
"${base}/../Resources/suwayomi-bundle" \
|
||||||
|
"${base}/suwayomi-bundle" \
|
||||||
|
"${base}/../suwayomi-bundle"
|
||||||
|
do
|
||||||
|
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||||
|
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||||
|
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
BUNDLE=$(find_bundle "$DIR") || {
|
||||||
|
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||||
|
echo "[sidecar] Tried:" >&2
|
||||||
|
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/../suwayomi-bundle" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAVA="${BUNDLE}/jre/bin/java"
|
||||||
|
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||||
|
|
||||||
|
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||||
|
echo "[sidecar] JAVA=$JAVA" >&2
|
||||||
|
echo "[sidecar] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||||
|
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||||
|
# We call java directly so all JVM flags reach it properly.
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
"$@" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:app:allow-default-window-icon",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
@@ -25,6 +29,17 @@
|
|||||||
"core:window:allow-outer-size",
|
"core:window:allow-outer-size",
|
||||||
"core:window:allow-inner-position",
|
"core:window:allow-inner-position",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor"
|
"core:window:allow-scale-factor",
|
||||||
|
"process:default",
|
||||||
|
"process:allow-restart",
|
||||||
|
"http:default",
|
||||||
|
"http:allow-fetch",
|
||||||
|
"store:default",
|
||||||
|
"discord-rpc:default",
|
||||||
|
"discord-rpc:allow-connect",
|
||||||
|
"discord-rpc:allow-disconnect",
|
||||||
|
"discord-rpc:allow-set-activity",
|
||||||
|
"discord-rpc:allow-clear-activity",
|
||||||
|
"discord-rpc:allow-is-running"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "http-scope",
|
||||||
|
"description": "HTTP fetch scope",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*:*/*" },
|
||||||
|
{ "url": "https://*:*/*" },
|
||||||
|
{ "url": "http://*/*" },
|
||||||
|
{ "url": "https://*/*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||||
|
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("auto-moku-backup-")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_local_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let content = std::fs::read_to_string(base.join(&name))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
(name, content)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_hello {
|
||||||
|
use windows::{
|
||||||
|
core::HSTRING,
|
||||||
|
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
|
||||||
|
Win32::UI::WindowsAndMessaging::{
|
||||||
|
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
std::ffi::OsStr::new(s)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_focus_hello_dialog() -> bool {
|
||||||
|
let cls = to_wide("Credential Dialog Xaml Host");
|
||||||
|
unsafe {
|
||||||
|
let Ok(hwnd) = FindWindowW(
|
||||||
|
windows::core::PCWSTR(cls.as_ptr()),
|
||||||
|
windows::core::PCWSTR::null(),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if IsIconic(hwnd).as_bool() {
|
||||||
|
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
}
|
||||||
|
let _ = BringWindowToTop(hwnd);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nudge_focus(retries: u32, delay_ms: u64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
for _ in 0..retries {
|
||||||
|
if try_focus_hello_dialog() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
|
let reason = reason.to_owned();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||||
|
.and_then(|op| {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
op.get()
|
||||||
|
});
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
UserConsentVerificationResult::Verified => Ok(()),
|
||||||
|
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
|
||||||
|
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
|
||||||
|
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
|
||||||
|
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
|
||||||
|
_ => Err("authenticationFailed".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_available() -> bool {
|
||||||
|
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||||
|
UserConsentVerifier::CheckAvailabilityAsync()
|
||||||
|
.and_then(|op| op.get())
|
||||||
|
.map(|a| a == UserConsentVerifierAvailability::Available)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::authenticate(&_reason);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
Err("notSupported".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_available() -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::is_available();
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod backup;
|
||||||
|
pub mod biometric;
|
||||||
|
pub mod server;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod system;
|
||||||
|
pub mod updater;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
|
||||||
|
use crate::ServerState;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||||
|
{
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if state.0.lock().unwrap().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
|
let mut log = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
server::conf::seed_server_conf(&data_dir);
|
||||||
|
|
||||||
|
let mut invocation =
|
||||||
|
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
|
let rootdir_flag = format!(
|
||||||
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
data_dir.to_string_lossy()
|
||||||
|
);
|
||||||
|
invocation.args.insert(0, rootdir_flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let working_dir = invocation
|
||||||
|
.working_dir
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!(
|
||||||
|
"[spawn_server] bin={:?} args={:?} cwd={:?}",
|
||||||
|
invocation.bin, invocation.args, working_dir
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
let cmd = app
|
||||||
|
.shell()
|
||||||
|
.command(&invocation.bin)
|
||||||
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
|
.args(&invocation.args)
|
||||||
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
server::kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use sysinfo::Disks;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StorageInfo {
|
||||||
|
pub manga_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub free_bytes: u64,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
|
if !downloads_path.trim().is_empty() {
|
||||||
|
return PathBuf::from(downloads_path.trim());
|
||||||
|
}
|
||||||
|
suwayomi_data_dir().join("downloads")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||||
|
let path = resolve_downloads_path(&downloads_path);
|
||||||
|
|
||||||
|
let manga_bytes = if path.exists() {
|
||||||
|
WalkDir::new(&path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| e.metadata().ok())
|
||||||
|
.filter(|m| m.is_file())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let stat_path = if path.exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
let disk = disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
|
Ok(StorageInfo {
|
||||||
|
manga_bytes,
|
||||||
|
total_bytes: disk.total_space(),
|
||||||
|
free_bytes: disk.available_space(),
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_default_downloads_path() -> String {
|
||||||
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_path_exists(path: String) -> bool {
|
||||||
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn migrate_downloads(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
src: String,
|
||||||
|
dst: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = PathBuf::from(src.trim());
|
||||||
|
let dst_path = PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(&src_path)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit(
|
||||||
|
"migrate_progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use crate::server::resolve::strip_unc;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restart_app(app: tauri::AppHandle) {
|
||||||
|
tauri::process::restart(&app.env());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_path(path: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let p = strip_unc(PathBuf::from(path.trim()));
|
||||||
|
std::process::Command::new("explorer")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let p = std::path::Path::new(path.trim());
|
||||||
|
std::process::Command::new("xdg-open")
|
||||||
|
.arg(p)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Downloads Folder")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn exit_app(app: tauri::AppHandle) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use tauri::Manager;
|
||||||
|
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||||
|
if cache_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for dir in &["cache", "bin/kcef", "cache/kcef"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
crate::server::kill_tachidesk(&app);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
|
||||||
|
let p = data_dir.join(entry_name);
|
||||||
|
if p.is_dir() {
|
||||||
|
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
} else if p.exists() {
|
||||||
|
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct ReleaseInfo {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
struct UpdateProgress {
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GhRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
published_at: Option<String>,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let releases: Vec<GhRelease> =
|
||||||
|
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ReleaseInfo {
|
||||||
|
tag_name: r.tag_name.clone(),
|
||||||
|
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||||
|
body: r.body.unwrap_or_default(),
|
||||||
|
published_at: r.published_at.unwrap_or_default(),
|
||||||
|
html_url: r.html_url,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!(
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
|
||||||
|
tag
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"GitHub API returned {} for tag {}",
|
||||||
|
resp.status(),
|
||||||
|
tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
|
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||||
|
|
||||||
|
let total = if asset.size > 0 {
|
||||||
|
Some(asset.size)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut resp = client
|
||||||
|
.get(&asset.browser_download_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||||
|
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
std::process::Command::new(&tmp_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = app.emit("update-launching", ());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,437 +1,55 @@
|
|||||||
use std::path::PathBuf;
|
mod commands;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::Write;
|
|
||||||
use sysinfo::Disks;
|
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
struct ServerState(Mutex<Option<CommandChild>>);
|
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct StorageInfo {
|
|
||||||
manga_bytes: u64,
|
|
||||||
total_bytes: u64,
|
|
||||||
free_bytes: u64,
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
#[serde(tag = "kind", content = "message")]
|
|
||||||
pub enum SpawnError {
|
|
||||||
NotConfigured(String),
|
|
||||||
SpawnFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
|
|
||||||
/// Java and many other tools do not accept this prefix and will fail silently.
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
|
||||||
let s = path.to_string_lossy();
|
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
||||||
PathBuf::from(stripped)
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|
||||||
if !downloads_path.trim().is_empty() {
|
|
||||||
return PathBuf::from(downloads_path);
|
|
||||||
}
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
|
|
||||||
base.join("Tachidesk/downloads")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|
||||||
let path = resolve_downloads_path(&downloads_path);
|
|
||||||
|
|
||||||
let manga_bytes = if path.exists() {
|
|
||||||
WalkDir::new(&path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter_map(|e| e.metadata().ok())
|
|
||||||
.filter(|m| m.is_file())
|
|
||||||
.map(|m| m.len())
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let stat_path = if path.exists() {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let disks = Disks::new_with_refreshed_list();
|
|
||||||
let disk = disks
|
|
||||||
.iter()
|
|
||||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
|
||||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
|
||||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
|
||||||
|
|
||||||
Ok(StorageInfo {
|
|
||||||
manga_bytes,
|
|
||||||
total_bytes: disk.total_space(),
|
|
||||||
free_bytes: disk.available_space(),
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_platform_ui_scale() -> f64 {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
return 1.0;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
return 1.0;
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
return 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if let Some(child) = state.0.lock().unwrap().take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let _ = std::process::Command::new("taskkill")
|
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java*"])
|
|
||||||
.status();
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let _ = std::process::Command::new("pkill")
|
|
||||||
.args(["-f", "tachidesk"])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
|
||||||
server.port = 4567
|
|
||||||
server.webUIEnabled = false
|
|
||||||
server.initialOpenInBrowserEnabled = false
|
|
||||||
server.systemTrayEnabled = false
|
|
||||||
server.webUIInterface = "browser"
|
|
||||||
server.webUIFlavor = "WebUI"
|
|
||||||
server.webUIChannel = "stable"
|
|
||||||
server.electronPath = ""
|
|
||||||
server.debugLogsEnabled = false
|
|
||||||
server.downloadAsCbz = true
|
|
||||||
server.autoDownloadNewChapters = false
|
|
||||||
server.globalUpdateInterval = 12
|
|
||||||
server.maxSourcesInParallel = 6
|
|
||||||
server.extensionRepos = []
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn seed_server_conf(data_dir: &PathBuf) {
|
|
||||||
let conf_path = data_dir.join("server.conf");
|
|
||||||
|
|
||||||
if !conf_path.exists() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
|
||||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
|
||||||
eprintln!("Could not write server.conf: {e}");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
|
||||||
|
|
||||||
let patched = patch_conf_key(
|
|
||||||
patch_conf_key(
|
|
||||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
|
||||||
"server.initialOpenInBrowserEnabled", "false",
|
|
||||||
),
|
|
||||||
"server.systemTrayEnabled", "false",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::write(&conf_path, patched);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
|
||||||
let replacement = format!("{key} = {value}");
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
|
|
||||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
|
||||||
let mut out = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
out.push('\n');
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = text;
|
|
||||||
if !out.ends_with('\n') { out.push('\n'); }
|
|
||||||
out.push_str(&replacement);
|
|
||||||
out.push('\n');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suwayomi_data_dir() -> PathBuf {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
|
||||||
.join("moku\\tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
|
||||||
.join("dev.moku.app/tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
|
||||||
base.join("moku/tachidesk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ServerInvocation {
|
|
||||||
bin: String,
|
|
||||||
args: Vec<String>,
|
|
||||||
working_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java.exe");
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] checking path: {:?}", java));
|
|
||||||
do_log(log, &format!("[find_java] exists: {}", java.exists()));
|
|
||||||
|
|
||||||
if java.exists() { Some(java) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
|
||||||
eprintln!("{}", msg);
|
|
||||||
if let Some(f) = log {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_server_binary(
|
|
||||||
binary: &str,
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
log: &mut Option<std::fs::File>,
|
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
|
||||||
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
|
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
|
||||||
do_log(log, "[resolve] using user-supplied binary path");
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: binary.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let resource_dir = match app.path().resource_dir() {
|
|
||||||
Ok(p) => {
|
|
||||||
let stripped = strip_unc(p);
|
|
||||||
do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped));
|
|
||||||
stripped
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("resource_dir error: {e}");
|
|
||||||
do_log(log, &format!("[resolve] ERROR: {}", msg));
|
|
||||||
return Err(SpawnError::SpawnFailed(msg));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir = {:?}", bundle_dir));
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists()));
|
|
||||||
do_log(log, &format!("[resolve] jar = {:?}", jar));
|
|
||||||
do_log(log, &format!("[resolve] jar exists: {}", jar.exists()));
|
|
||||||
|
|
||||||
match find_java_in_bundle(&bundle_dir, log) {
|
|
||||||
Some(java) => {
|
|
||||||
do_log(log, &format!("[resolve] java found: {:?}", java));
|
|
||||||
if jar.exists() {
|
|
||||||
do_log(log, "[resolve] both java and jar found — using bundled JRE");
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec![
|
|
||||||
"-jar".to_string(),
|
|
||||||
jar.to_string_lossy().into_owned(),
|
|
||||||
],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
do_log(log, "[resolve] java found but jar MISSING — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
do_log(log, "[resolve] java NOT found in bundle — skipping bundled path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let candidates = [
|
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
|
||||||
"suwayomi-server",
|
|
||||||
];
|
|
||||||
for name in &candidates {
|
|
||||||
let p = resource_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
|
||||||
if p.exists() {
|
|
||||||
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
|
||||||
let found = std::process::Command::new("which")
|
|
||||||
.arg(name)
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
|
|
||||||
|
|
||||||
if found {
|
|
||||||
do_log(log, &format!("[resolve] using PATH binary: {}", name));
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: name.to_string(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] FAILED — no binary found anywhere");
|
|
||||||
Err(SpawnError::NotConfigured(
|
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
|
||||||
{
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if state.0.lock().unwrap().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
|
||||||
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
|
||||||
let mut log = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&log_path)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
do_log(&mut log, "");
|
|
||||||
do_log(&mut log, "========================================");
|
|
||||||
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
|
||||||
do_log(&mut log, "[spawn_server] server.conf seeded");
|
|
||||||
|
|
||||||
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bin_display = invocation.bin.clone();
|
|
||||||
let rootdir_flag = format!(
|
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
|
||||||
data_dir.to_string_lossy()
|
|
||||||
);
|
|
||||||
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
|
||||||
|
|
||||||
let working_dir = invocation.working_dir
|
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
|
||||||
.command(&invocation.bin)
|
|
||||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
|
||||||
.args(&invocation.args)
|
|
||||||
.current_dir(&working_dir);
|
|
||||||
|
|
||||||
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok((_rx, child)) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
|
|
||||||
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
kill_tachidesk(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
commands::storage::get_storage_info,
|
||||||
spawn_server,
|
commands::storage::get_default_downloads_path,
|
||||||
kill_server,
|
commands::storage::check_path_exists,
|
||||||
get_platform_ui_scale,
|
commands::storage::create_directory,
|
||||||
|
commands::storage::migrate_downloads,
|
||||||
|
commands::server::spawn_server,
|
||||||
|
commands::server::kill_server,
|
||||||
|
commands::system::get_platform_ui_scale,
|
||||||
|
commands::system::restart_app,
|
||||||
|
commands::system::exit_app,
|
||||||
|
commands::system::clear_moku_cache,
|
||||||
|
commands::system::clear_suwayomi_cache,
|
||||||
|
commands::system::reset_suwayomi_data,
|
||||||
|
commands::system::open_path,
|
||||||
|
commands::system::pick_downloads_folder,
|
||||||
|
commands::backup::export_app_data,
|
||||||
|
commands::backup::import_app_data,
|
||||||
|
commands::backup::auto_backup_app_data,
|
||||||
|
commands::backup::get_auto_backup_dir,
|
||||||
|
commands::backup::read_store_files,
|
||||||
|
commands::updater::list_releases,
|
||||||
|
commands::updater::download_and_install_update,
|
||||||
|
commands::biometric::windows_hello_authenticate,
|
||||||
|
commands::biometric::windows_hello_available,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
kill_tachidesk(window.app_handle());
|
server::kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
moku_lib::run();
|
moku_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = true
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "preview"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
if !conf_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||||
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||||
|
eprintln!("Could not write server.conf: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(contents, "server.webUIEnabled", "true"),
|
||||||
|
"server.initialOpenInBrowserEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let mut out = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = text;
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&replacement);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pub mod conf;
|
||||||
|
pub mod resolve;
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::ServerState;
|
||||||
|
|
||||||
|
pub use resolve::SpawnError;
|
||||||
|
|
||||||
|
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(f) = log {
|
||||||
|
let _ = writeln!(f, "{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args(["-f", "tachidesk"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
use crate::server::do_log;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
#[serde(tag = "kind", content = "message")]
|
||||||
|
pub enum SpawnError {
|
||||||
|
NotConfigured(String),
|
||||||
|
SpawnFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerInvocation {
|
||||||
|
pub bin: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("Tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||||
|
base.join("Tachidesk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||||
|
PathBuf::from(stripped)
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
|
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
|
||||||
|
);
|
||||||
|
if java.exists() {
|
||||||
|
Some(java)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_root_args() -> Vec<String> {
|
||||||
|
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jar_data_root_flag() -> String {
|
||||||
|
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_server_binary(
|
||||||
|
binary: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
log: &mut Option<std::fs::File>,
|
||||||
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
|
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||||
|
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] user path: {:?} exists={}", path, path.exists()),
|
||||||
|
);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: path.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin_dir) = exe.parent() {
|
||||||
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
|
let p = bin_dir.join(name);
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] sibling: {:?} exists={}", p, p.exists()),
|
||||||
|
);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(bin_dir.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let resource_dir = {
|
||||||
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let stripped = strip_unc(raw);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
||||||
|
stripped
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!(
|
||||||
|
"[resolve] bundle_dir={:?} exists={}",
|
||||||
|
bundle_dir,
|
||||||
|
bundle_dir.exists()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] jar={:?} exists={}", jar, jar.exists()),
|
||||||
|
);
|
||||||
|
|
||||||
|
match find_java_in_bundle(&bundle_dir, log) {
|
||||||
|
Some(java) if jar.exists() => {
|
||||||
|
do_log(log, "[resolve] using bundled JRE");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in &[
|
||||||
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
|
] {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] sidecar: {:?} exists={}", p, p.exists()),
|
||||||
|
);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir).ok().and_then(|mut rd| {
|
||||||
|
rd.find(|e| {
|
||||||
|
e.as_ref()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path),
|
||||||
|
);
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![jar_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(resource_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf();
|
||||||
|
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const NATIVE_NAMES: &[&str] = &[
|
||||||
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
"suwayomi-server",
|
||||||
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_binary: Option<ServerInvocation> = None;
|
||||||
|
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||||
|
|
||||||
|
'outer: for depth in 0u8..=8 {
|
||||||
|
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||||
|
.min_depth(depth as usize)
|
||||||
|
.max_depth(depth as usize)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_dir())
|
||||||
|
.map(|e| e.into_path())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for dir in &entries {
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] scanning depth={} dir={:?}", depth, dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
for name in NATIVE_NAMES {
|
||||||
|
let p = dir.join(name);
|
||||||
|
if p.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||||
|
found_binary = Some(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(dir.clone()),
|
||||||
|
});
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_java.is_none() {
|
||||||
|
let java_exe = dir.join("bin").join("java");
|
||||||
|
if java_exe.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||||
|
let mut search = dir.as_path();
|
||||||
|
'jar: for _ in 0..5 {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(search) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bin_sibling = search.join("bin");
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] found jar in bin/: {:?}", jar),
|
||||||
|
);
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match search.parent() {
|
||||||
|
Some(p) => search = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(inv) = found_binary {
|
||||||
|
return Ok(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((java, jar)) = found_java {
|
||||||
|
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
|
working_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let resolved = std::process::Command::new("where")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let resolved = std::process::Command::new("which")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||||
|
|
||||||
|
if let Some(bin_path) = resolved {
|
||||||
|
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: bin_path,
|
||||||
|
args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SpawnError::NotConfigured(
|
||||||
|
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.4.0",
|
"version": "0.9.3",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
@@ -27,9 +27,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [
|
"targets": ["nsis"],
|
||||||
"nsis"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -51,4 +49,4 @@
|
|||||||
"open": true
|
"open": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage", "deb"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-launcher-linux"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||||
|
"binaries/suwayomi-bundle/jre": "jre"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"decorations": true,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["dmg"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-server"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "11.0",
|
||||||
|
"exceptionDomain": "localhost",
|
||||||
|
"frameworks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,4 @@
|
|||||||
"binaries/suwayomi-bundle/jre/**/*"
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,387 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { gql } from "./lib/client";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
import { defaultWindowIcon } from "@tauri-apps/api/app";
|
||||||
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
import { TrayIcon } from "@tauri-apps/api/tray";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import { Menu } from "@tauri-apps/api/menu";
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||||
import Toaster from "./components/layout/Toaster.svelte";
|
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
import { applyTheme } from "@core/theme";
|
||||||
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
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 MAX_ATTEMPTS = 60;
|
const win = getCurrentWindow();
|
||||||
|
void platform();
|
||||||
|
|
||||||
let serverProbeOk = $state(!store.settings.autoStartServer);
|
let appReady = $state(false);
|
||||||
let appReady = $state(!store.settings.autoStartServer);
|
let idle = $state(false);
|
||||||
let failed = $state(false);
|
let devSplash = $state(false);
|
||||||
let notConfigured = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
let platformScale = $state(1);
|
|
||||||
|
|
||||||
function applyZoom() {
|
let themeEditorOpen = $state(false);
|
||||||
const normalized = store.settings.uiScale * platformScale;
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
document.documentElement.style.zoom = `${normalized}%`;
|
|
||||||
document.documentElement.style.setProperty("--ui-scale", String(normalized));
|
let closeDialogOpen = $state(false);
|
||||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (normalized / 100)}px`);
|
let closeRemember = $state(false);
|
||||||
|
|
||||||
|
function openThemeEditor(id?: string | null) {
|
||||||
|
themeEditorEditId = id ?? null;
|
||||||
|
themeEditorOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prevQueue: DownloadQueueItem[] = [];
|
function closeThemeEditor() {
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
themeEditorOpen = false;
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
themeEditorEditId = null;
|
||||||
let unlistenDownload: (() => void) | undefined;
|
}
|
||||||
|
|
||||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
async function doQuit() {
|
||||||
for (const item of prev) {
|
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
await win.destroy();
|
||||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
}
|
||||||
const manga = item.chapter.manga;
|
|
||||||
addToast({ kind: "success", title: "Chapter downloaded",
|
async function doHide() {
|
||||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
await win.hide();
|
||||||
duration: 4000 });
|
}
|
||||||
}
|
|
||||||
|
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 (store.settings.discordRpc) {
|
||||||
|
initRpc();
|
||||||
|
} else {
|
||||||
|
clearReading();
|
||||||
|
destroyRpc();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function applyQueue(next: DownloadQueueItem[]) {
|
|
||||||
detectCompletions(prevQueue, next);
|
|
||||||
prevQueue = next;
|
|
||||||
setActiveDownloads(next.map(item => ({
|
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetIdle() {
|
|
||||||
if (idle) return;
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (ms === 0) return;
|
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idleEvents = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
|
||||||
resetIdle();
|
|
||||||
return () => idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Re-runs whenever uiScale or platformScale changes.
|
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||||
store.settings.uiScale; platformScale;
|
|
||||||
applyZoom();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
|
const next = downloadStore.queue.slice();
|
||||||
});
|
downloadStore.detectTransitions(next);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
|
||||||
poll();
|
|
||||||
pollInterval = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(pollInterval);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||||
|
|
||||||
// Fetch the platform scale factor then immediately re-apply zoom.
|
|
||||||
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
|
|
||||||
applyZoom();
|
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 menu = await Menu.new({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "show",
|
||||||
|
text: "Show Moku",
|
||||||
|
action: async () => {
|
||||||
|
await win.show();
|
||||||
|
await win.setFocus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quit",
|
||||||
|
text: "Quit",
|
||||||
|
action: doQuit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await TrayIcon.new({
|
||||||
|
icon: await defaultWindowIcon(),
|
||||||
|
menu,
|
||||||
|
menuOnLeftClick: false,
|
||||||
|
tooltip: "Moku",
|
||||||
|
action: async (e) => {
|
||||||
|
if (e.type === "Click") {
|
||||||
|
await win.show();
|
||||||
|
await win.setFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
if (store.settings.autoStartServer) {
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||||
if (err?.kind === "NotConfigured") {
|
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||||
notConfigured = true;
|
else console.warn("Could not start server:", err);
|
||||||
} else {
|
|
||||||
console.warn("Could not start server:", err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverProbeOk) {
|
await initStore();
|
||||||
let cancelled = false, tries = 0;
|
startProbe();
|
||||||
async function probe() {
|
|
||||||
if (cancelled) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${store.settings.serverUrl}/api/graphql`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelled) { serverProbeOk = true; return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; }
|
|
||||||
if (!cancelled) setTimeout(probe, 500);
|
|
||||||
}
|
|
||||||
setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
"download-progress",
|
||||||
|
e => setActiveDownloads(e.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
await downloadStore.poll();
|
||||||
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
stopProbe();
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
clearInterval(dlInterval);
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
unlistenResize();
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
unlistenScale();
|
||||||
unlistenDownload?.();
|
unlistenDownload();
|
||||||
|
unlistenClose();
|
||||||
|
destroyRpc();
|
||||||
delete (window as any).__mokuShowSplash;
|
delete (window as any).__mokuShowSplash;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady}
|
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
{:else if !appReady && !boot.loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||||
|
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => appReady = true}
|
onReady={() => { appReady = true; }}
|
||||||
onRetry={handleRetry} />
|
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}
|
{:else}
|
||||||
<div class="root">
|
{#if idle && !store.activeChapter}
|
||||||
{#if idle && !store.activeChapter}
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
onDismiss={() => { idle = false; }} />
|
||||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
{/if}
|
||||||
{/if}
|
|
||||||
|
{#if boot.sessionExpired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div id="app-shell" class="root">
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if store.settingsOpen}<Settings />{/if}
|
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||||
|
{#if themeEditorOpen}
|
||||||
|
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||||
|
{/if}
|
||||||
<MangaPreview />
|
<MangaPreview />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if closeDialogOpen}
|
||||||
|
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||||
|
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="close-header">
|
||||||
|
<p class="close-title">Close Moku?</p>
|
||||||
|
<p class="close-sub">Choose how the app should exit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="close-actions">
|
||||||
|
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||||
|
<span class="close-btn-label">Minimize to Tray</span>
|
||||||
|
<span class="close-btn-desc">Keep running in the background</span>
|
||||||
|
</button>
|
||||||
|
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||||
|
<span class="close-btn-label">Quit</span>
|
||||||
|
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||||
|
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||||
|
<span class="close-remember-label">Remember my choice</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; overflow: hidden; }
|
.content { flex: 1; overflow: hidden; }
|
||||||
</style>
|
|
||||||
|
.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,133 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { fetchAuthenticated, AuthRequiredError, uiAuth } 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 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) 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) {
|
||||||
|
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,122 @@
|
|||||||
|
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!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken {
|
||||||
|
refreshToken(input: {}) { accessToken }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const GET_RECENTLY_UPDATED = `
|
||||||
|
query GetRecentlyUpdated {
|
||||||
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
nodes {
|
||||||
|
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 baseUrl
|
||||||
|
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 baseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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,107 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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` progress and `mangaUpdates` list with new chapters |
|
||||||
|
| `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,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import Sidebar from "./Sidebar.svelte";
|
|
||||||
import Home from "../pages/Home.svelte";
|
|
||||||
import Library from "../pages/Library.svelte";
|
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
|
||||||
import History from "../pages/History.svelte";
|
|
||||||
import Search from "../pages/Search.svelte";
|
|
||||||
import Discover from "../pages/Discover.svelte";
|
|
||||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
|
||||||
import Downloads from "../pages/Downloads.svelte";
|
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<Sidebar />
|
|
||||||
<main class="main">
|
|
||||||
{#if store.activeManga}
|
|
||||||
<SeriesDetail />
|
|
||||||
{:else if store.navPage === "home"}
|
|
||||||
<Home />
|
|
||||||
{:else if store.navPage === "library"}
|
|
||||||
<Library />
|
|
||||||
{:else if store.navPage === "search"}
|
|
||||||
<Search />
|
|
||||||
{:else if store.navPage === "history"}
|
|
||||||
<History />
|
|
||||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
|
||||||
<Discover />
|
|
||||||
{:else if store.navPage === "downloads"}
|
|
||||||
<Downloads />
|
|
||||||
{:else if store.navPage === "extensions"}
|
|
||||||
<Extensions />
|
|
||||||
{:else}
|
|
||||||
<Home />
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
|
||||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; }
|
|
||||||
</style>
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClear = $state(false);
|
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next); j++;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
|
||||||
? store..filter((e) =>
|
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
: store.);
|
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
});
|
|
||||||
|
|
||||||
function resume(session: Session) {
|
|
||||||
const ch = store..find((c) => c.id === session.latestChapterId);
|
|
||||||
if (ch && store..length > 0) openReader(ch, );
|
|
||||||
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="page-header">
|
|
||||||
<span class="heading">History</span>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search store.…" bind:value={search} />
|
|
||||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
|
||||||
</div>
|
|
||||||
{#if store..length > 0}
|
|
||||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store..totalChaptersRead > 0}
|
|
||||||
<div class="stats-bar">
|
|
||||||
<div class="stat-group">
|
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
|
||||||
<span class="stat-val accent">{store..currentStreakDays}</span>
|
|
||||||
<span class="stat-label">day streak</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store..totalChaptersRead}</span>
|
|
||||||
<span class="stat-label">chapters</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
|
||||||
<span class="stat-label">read time</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
|
||||||
<span class="stat-val">{store..totalMangaRead}</span>
|
|
||||||
<span class="stat-label">series</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-sep"></div>
|
|
||||||
<div class="stat-group">
|
|
||||||
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
|
||||||
<span class="stat-label">best streak</span>
|
|
||||||
</div>
|
|
||||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if store..length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading store.</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="timeline">
|
|
||||||
{#each groups as { label, items }}
|
|
||||||
<div class="day-group">
|
|
||||||
<div class="day-label-row">
|
|
||||||
<span class="day-label">{label}</span>
|
|
||||||
<div class="day-line"></div>
|
|
||||||
</div>
|
|
||||||
<div class="session-list">
|
|
||||||
{#each items as session (session.latestChapterId)}
|
|
||||||
<button class="session-row" onclick={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="session-info">
|
|
||||||
<span class="session-title">{session.mangaTitle}</span>
|
|
||||||
<span class="session-chapter">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span class="ch-arrow">→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}
|
|
||||||
<span class="ch-page">p.{session.latestPageNumber}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="session-time">{timeAgo(session.readAt)}</span>
|
|
||||||
<div class="play-pill">
|
|
||||||
<Play size={10} weight="fill" /> Resume
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
.page-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); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 26px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); background: none; border: 1px solid transparent;
|
|
||||||
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
|
||||||
|
|
||||||
.stats-bar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.stat-group { display: flex; align-items: center; gap: 5px; }
|
|
||||||
.stat-sep { width: 1px; height: 14px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
:global(.stat-fire) { color: #f97316; }
|
|
||||||
:global(.stat-icon-neutral) { color: var(--text-faint); }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.stat-val.accent { color: var(--accent-fg); }
|
|
||||||
.stat-val.muted { color: var(--text-faint); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
|
||||||
|
|
||||||
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.day-group { margin-bottom: var(--sp-5); }
|
|
||||||
.day-label-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
|
|
||||||
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
|
|
||||||
.day-line { flex: 1; height: 1px; background: var(--border-dim); }
|
|
||||||
|
|
||||||
.session-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.session-row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px 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);
|
|
||||||
}
|
|
||||||
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.session-count {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
.play-pill {
|
|
||||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
padding: 3px 8px; border-radius: var(--radius-full);
|
|
||||||
opacity: 0; transform: translateX(4px);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
|
||||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import type { NavPage } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
|
||||||
{ id: "home", label: "Home", icon: House },
|
|
||||||
{ id: "library", label: "Library", icon: Books },
|
|
||||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
|
||||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
|
||||||
{ id: "explore", label: "Discover", icon: Compass },
|
|
||||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
|
||||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
|
||||||
];
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
store.navPage = id;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.genreFilter = "";
|
|
||||||
if (id !== "explore") store.activeSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
store.navPage = "home";
|
|
||||||
store.activeSource = null;
|
|
||||||
store.activeManga = null;
|
|
||||||
store.libraryFilter = "library";
|
|
||||||
store.genreFilter = "";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="root">
|
|
||||||
<button class="logo" onclick={goHome} title="Home" aria-label="Go to Home">
|
|
||||||
<div class="logo-icon"></div>
|
|
||||||
</button>
|
|
||||||
<nav class="nav">
|
|
||||||
{#each TABS as tab}
|
|
||||||
<button class="tab" class:active={store.navPage === tab.id}
|
|
||||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
|
||||||
<tab.icon size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
<div class="bottom">
|
|
||||||
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: var(--bg-void); display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; }
|
|
||||||
.logo { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-3); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; appearance: none; -webkit-appearance: none; }
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.logo-icon { width: 67px; height: 67px; background-color: var(--accent); mask-image: url("../../assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("../../assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
|
||||||
.nav { flex: 1; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2); }
|
|
||||||
.tab { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.bottom { display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
|
||||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
|
||||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import logoUrl from "../../assets/moku-icon-splash.svg";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode?: "loading" | "idle";
|
|
||||||
ringFull?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
notConfigured?: boolean;
|
|
||||||
showCards?: boolean;
|
|
||||||
showFps?: boolean;
|
|
||||||
onReady?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
|
||||||
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
|
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
// Server typically takes 8-20s to boot. We animate the ring through three
|
|
||||||
// phases so it always feels like something is happening:
|
|
||||||
// 0 → 0.75 over ~12s (eased crawl while server starts)
|
|
||||||
// 0.75 → 0.92 over ~8s (slow down near the end, implying "almost there")
|
|
||||||
// jumps to 1.0 the moment the probe succeeds
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate ring progress with easing so it never stalls visually
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
// ease-out cubic so it starts fast and slows down
|
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
|
||||||
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
|
||||||
} else if (animPhase === 2) {
|
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
||||||
const eased = 1 - Math.pow(1 - t, 4);
|
|
||||||
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
// Phase 2 never completes on its own — only ringFull triggers completion
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mode === "loading" && !failed && !notConfigured) {
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
return () => cancelAnimationFrame(animFrame);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (ringFull) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
ringProg = 1;
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
clearInterval(dotsInterval);
|
|
||||||
window.removeEventListener("keydown", handler);
|
|
||||||
window.removeEventListener("mousedown", handler);
|
|
||||||
window.removeEventListener("touchstart", handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
|
||||||
|
|
||||||
const LAYER_CFG = [
|
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
|
||||||
|
|
||||||
function hash(n: number): number {
|
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
|
||||||
const cards: CardDef[] = [], laneW = vw / COLS;
|
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
|
||||||
const cfg = LAYER_CFG[layer];
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
|
||||||
const h = w * 1.44;
|
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
|
||||||
const travel = vh + h + BUF;
|
|
||||||
cards.push({
|
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
|
||||||
w, h, lines: 1 + Math.floor(hash(seed + 7) * 3), alpha: cfg.alpha, speed,
|
|
||||||
cycleSec: travel / speed,
|
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
|
||||||
travel, yStart: vh + h / 2 + BUF / 2,
|
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
|
||||||
}));
|
|
||||||
return { cards, trigs };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
|
||||||
const coverH = (c.w * 0.72) * 1.05;
|
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
|
||||||
}
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(vw * dpr); oc.height = Math.round(vh * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.4, "rgba(0,0,0,0)"); g.addColorStop(0.7, "rgba(0,0,0,0.25)"); g.addColorStop(1, "rgba(0,0,0,0.65)");
|
|
||||||
ctx.fillStyle = g; ctx.fillRect(0, 0, vw, vh);
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFrame(
|
|
||||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
|
||||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
|
||||||
) {
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
|
||||||
for (let i = 0; i < cards.length; i++) {
|
|
||||||
const c = cards[i];
|
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
|
||||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
|
||||||
if (alpha < 0.005) continue;
|
|
||||||
const cy = c.yStart - p * c.travel;
|
|
||||||
const tg = trigs[i];
|
|
||||||
const delta = tg.tiltRad * p;
|
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
|
||||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
|
||||||
}
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1;
|
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
|
||||||
function tickFps(now: number) {
|
|
||||||
fpsFrames++;
|
|
||||||
if (now - fpsLast >= 500) {
|
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
|
||||||
fpsFrames = 0; fpsLast = now;
|
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
const ctx = el.getContext("2d")!;
|
|
||||||
interface RenderState {
|
|
||||||
cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[];
|
|
||||||
vignette: HTMLCanvasElement; CW: number; CH: number; scale: number;
|
|
||||||
}
|
|
||||||
let live: RenderState | null = null;
|
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
|
||||||
|
|
||||||
async function syncSize() {
|
|
||||||
const gen = ++buildGen;
|
|
||||||
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
|
||||||
if (gen !== buildGen) return;
|
|
||||||
const logW = phys.width / scale, logH = phys.height / scale;
|
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
|
||||||
const built = buildCards(logW, logH);
|
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
|
||||||
const vig = buildVignette(logW, logH, scale);
|
|
||||||
el.width = phys.width; el.height = phys.height;
|
|
||||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
|
||||||
ro.observe(el); syncSize();
|
|
||||||
|
|
||||||
let raf = 0, t0 = -1;
|
|
||||||
function frame(now: number) {
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (!live) return;
|
|
||||||
if (t0 < 0) t0 = now;
|
|
||||||
if (showFps) tickFps(now);
|
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
|
||||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ringR = $derived(70);
|
|
||||||
const ringPad = $derived(12);
|
|
||||||
const ringSize = $derived((ringR + ringPad) * 2);
|
|
||||||
const ringC = $derived(ringR + ringPad);
|
|
||||||
const ringCirc = $derived(2 * Math.PI * ringR);
|
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
|
||||||
const ringTop = $derived(-((ringSize - 140) / 2));
|
|
||||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' ? 'pointer' : 'default'}">
|
|
||||||
{#if showCards}
|
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
|
||||||
{#if showFps}
|
|
||||||
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === "idle"}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
|
||||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<p class="hint">press any key to continue</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
|
||||||
{#if !failed && !notConfigured}
|
|
||||||
<svg width={ringSize} height={ringSize} style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-dasharray="{ringArc} {ringCirc}" transform="rotate(-90 {ringC} {ringC})" style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
|
||||||
</div>
|
|
||||||
<p class="title-label">moku</p>
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
|
|
||||||
{#if notConfigured}
|
|
||||||
<div class="error-box">
|
|
||||||
<p class="error-title">Server not configured</p>
|
|
||||||
<p class="error-body">Set the server path in Settings, then retry</p>
|
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
|
||||||
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
|
|
||||||
<button class="retry-btn" onclick={onRetry}>Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if failed}
|
|
||||||
<div class="error-box error-box--danger">
|
|
||||||
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
|
|
||||||
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
|
|
||||||
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.12em;margin:0;min-width:160px;text-align:center">
|
|
||||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
|
||||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
|
||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
|
||||||
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
|
|
||||||
.retry-btn { margin-top: 4px; padding: 5px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.08em; }
|
|
||||||
.retry-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 20px; border-radius: var(--radius-lg); background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); max-width: 260px; text-align: center; backdrop-filter: blur(4px); }
|
|
||||||
.error-box--danger { border-color: rgba(220,50,50,0.5); }
|
|
||||||
.error-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.1em; margin: 0; }
|
|
||||||
.error-body { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.05em; margin: 0; line-height: 1.6; }
|
|
||||||
</style>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bar" data-tauri-drag-region>
|
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
|
||||||
<div class="controls">
|
|
||||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.close:hover { color: #fff; background: #c0392b; }
|
|
||||||
</style>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { store, dismissToast } from "../../store/state.svelte";
|
|
||||||
import type { Toast } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
function schedule(t: Toast) {
|
|
||||||
if (timers.has(t.id)) return;
|
|
||||||
const dur = t.duration ?? 3500;
|
|
||||||
if (dur === 0) return;
|
|
||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
store.toasts.forEach(schedule);
|
|
||||||
return () => timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
|
||||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.toasts.length}
|
|
||||||
<div class="toaster" aria-live="polite">
|
|
||||||
{#each store.toasts as t (t.id)}
|
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d={icons[t.kind]} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div class="body">
|
|
||||||
<p class="title">{t.title}</p>
|
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toaster {
|
|
||||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
|
||||||
z-index: 9999; display: flex; flex-direction: column;
|
|
||||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all; min-width: 220px;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
|
||||||
}
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
.toast-success { border-color: var(--accent-dim); }
|
|
||||||
.toast-success .icon { color: var(--accent-fg); }
|
|
||||||
.toast-error { border-color: var(--color-error); }
|
|
||||||
.toast-error .icon { color: var(--color-error); }
|
|
||||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
|
||||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
|
||||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
|
||||||
.sub {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
</style>
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
|
||||||
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 60; // max rendered per tab
|
|
||||||
const LOCAL_THRESHOLD = 20; // fan out to sources if local results below this
|
|
||||||
const CONCURRENCY = 4; // parallel source requests — kept conservative to not saturate connections
|
|
||||||
const BATCH_INTERVAL = 400; // ms between DOM updates during background source fan-out
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
|
||||||
mangas(
|
|
||||||
filter: { genre: { includesInsensitive: $genre } }
|
|
||||||
first: $first orderBy: IN_LIBRARY_AT orderByType: DESC
|
|
||||||
) { nodes { id title thumbnailUrl inLibrary genre status source { id displayName } } }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
|
||||||
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
|
||||||
let allSources: Source[] = $state([]); // all deduped sources — loaded once
|
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
|
|
||||||
// Per-genre result map. Keyed by genre string.
|
|
||||||
// "All" key → local library deduped by title
|
|
||||||
// Each tab key → local + background source results, deduped id+title
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
// batch timer handle for background source fan-out
|
|
||||||
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
// accumulator: source results collected between batches
|
|
||||||
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
|
||||||
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
|
|
||||||
|
|
||||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
|
||||||
let i = 0;
|
|
||||||
const worker = async () => {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Batched DOM flush ─────────────────────────────────────────────────────────
|
|
||||||
// Source fan-out collects results in batchAccum. A timer fires every BATCH_INTERVAL
|
|
||||||
// ms and flushes them into genreResults in one shot — preventing a Svelte re-render
|
|
||||||
// per-source and keeping the grid smooth.
|
|
||||||
function startBatchFlush() {
|
|
||||||
if (batchTimer) return;
|
|
||||||
batchTimer = setInterval(() => {
|
|
||||||
if (batchAccum.size === 0) return;
|
|
||||||
for (const [genre, incoming] of batchAccum) {
|
|
||||||
const current = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
|
||||||
}
|
|
||||||
batchAccum.clear();
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}, BATCH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopBatchFlush() {
|
|
||||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
|
||||||
// Final flush of anything remaining
|
|
||||||
if (batchAccum.size > 0) {
|
|
||||||
for (const [genre, incoming] of batchAccum) {
|
|
||||||
const current = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
|
||||||
}
|
|
||||||
batchAccum.clear();
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push source results into the accumulator (never touches the DOM directly)
|
|
||||||
function accumulate(genre: string, mangas: Manga[]) {
|
|
||||||
const existing = batchAccum.get(genre) ?? [];
|
|
||||||
batchAccum.set(genre, [...existing, ...mangas]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Background source fan-out for a genre ────────────────────────────────────
|
|
||||||
// Runs entirely in the background. Results appear in batches via batchAccum.
|
|
||||||
// Does NOT set genreLoading = true — the local result is already showing.
|
|
||||||
async function fanOutSources(genre: string, ctrl: AbortController) {
|
|
||||||
if (!allSources.length) return;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const srcs = dedupeSources(allSources, lang);
|
|
||||||
|
|
||||||
startBatchFlush();
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, [genre]);
|
|
||||||
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: 1, query: genre }, ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga),
|
|
||||||
5 * 60 * 1000, // 5-min TTL — results are stable enough to cache
|
|
||||||
).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
// Only accumulate results that actually match the genre (client-side AND check)
|
|
||||||
const matching = result.mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
|| result.mangas.length <= 5 // source returns few results, trust them
|
|
||||||
);
|
|
||||||
|
|
||||||
accumulate(genre, matching.length > 0 ? matching : result.mangas);
|
|
||||||
}, ctrl.signal);
|
|
||||||
|
|
||||||
if (!ctrl.signal.aborted) stopBatchFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tab switch ───────────────────────────────────────────────────────────────
|
|
||||||
// 1. Show local results immediately (no spinner if already cached)
|
|
||||||
// 2. If local < LOCAL_THRESHOLD, kick off background fan-out silently
|
|
||||||
async function switchGenre(genre: string) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
// Abort any in-flight fan-out for the previous tab
|
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
// "All" is just the deduped local library — no network needed
|
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we already have a fully-populated cache for this genre, show it instantly
|
|
||||||
const cached = genreResults.get(genre);
|
|
||||||
if (cached && cached.length >= LOCAL_THRESHOLD) return;
|
|
||||||
|
|
||||||
// Fetch local results (fast — single DB query)
|
|
||||||
genreLoading = true;
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
genreAbort = ctrl;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localData = await cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal)
|
|
||||||
.then(d => d.mangas.nodes)
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(localData);
|
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = false;
|
|
||||||
|
|
||||||
// If sparse, fan out to sources in the background — no loading state shown
|
|
||||||
if (local.length < LOCAL_THRESHOLD) {
|
|
||||||
fanOutSources(genre, ctrl).catch(() => {}); // fully detached background task
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
||||||
}
|
|
||||||
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(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
|
||||||
},
|
|
||||||
...(store.settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...store.settings.folders.map(f => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add", icon: FolderSimplePlus,
|
|
||||||
onClick: () => {
|
|
||||||
const n = prompt("Folder name:");
|
|
||||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────────
|
|
||||||
// 1. Load local library → populate "All" tab immediately
|
|
||||||
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true; loadError = false;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
// Local library — populates "All" tab
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
allManga = dedupeMangaById(m);
|
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
// Source list — loaded silently in background, cached for the session
|
|
||||||
// Not awaited — the grid doesn't depend on this for the initial render
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => dedupeSources(d.sources.nodes, lang)),
|
|
||||||
Infinity, // pin for session — source list is stable
|
|
||||||
).then(srcs => {
|
|
||||||
allSources = srcs;
|
|
||||||
}).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(loadAll);
|
|
||||||
onDestroy(() => {
|
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<!-- ── Header: page label + genre pill tabs ──────────────────────────────── -->
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">Discover</span>
|
|
||||||
<div class="tab-strip">
|
|
||||||
{#each GENRE_TABS as tab (tab)}
|
|
||||||
<button
|
|
||||||
class="genre-tab"
|
|
||||||
class:active={currentGenre === tab}
|
|
||||||
onclick={() => switchGenre(tab)}
|
|
||||||
>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Body ──────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
{#if isLoading}
|
|
||||||
<!-- Skeleton — shown only during first local fetch, never during bg fan-out -->
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each Array(24) as _, i (i)}
|
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadError && visibleGrid.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
|
||||||
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each visibleGrid as m (m.id)}
|
|
||||||
<button
|
|
||||||
class="manga-card"
|
|
||||||
onclick={() => setPreviewManga(m)}
|
|
||||||
oncontextmenu={(e) => openCtx(e, m)}
|
|
||||||
>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img
|
|
||||||
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
|
||||||
class="cover" loading="lazy" decoding="async"
|
|
||||||
/>
|
|
||||||
<div class="cover-gradient"></div>
|
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}
|
|
||||||
<p class="card-source">{m.source.displayName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#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 ──────────────────────────────────────────────────────────────── */
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
overflow-x: auto; scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre pill tabs */
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.genre-tab {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 12px; border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
|
||||||
cursor: pointer; white-space: nowrap;
|
|
||||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
|
||||||
.body {
|
|
||||||
flex: 1; overflow-y: auto;
|
|
||||||
padding: var(--sp-4) var(--sp-5) var(--sp-6);
|
|
||||||
/* GPU-accelerated scroll — does NOT promote every card, only the scroll container */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Grid ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr));
|
|
||||||
gap: var(--sp-2);
|
|
||||||
align-content: start;
|
|
||||||
/* Isolate the grid from the rest of the layout — prevents full-page reflow on update */
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-card {
|
|
||||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
|
||||||
/* NO will-change here — only promote on actual hover to avoid 60+ simultaneous GPU layers */
|
|
||||||
}
|
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
|
||||||
/* Promote only the hovered card to its own GPU layer */
|
|
||||||
.manga-card:hover { will-change: transform; }
|
|
||||||
|
|
||||||
.cover-wrap {
|
|
||||||
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
|
||||||
border-radius: var(--radius-md); background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
.cover {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
||||||
transition: filter 0.15s ease, transform 0.15s ease;
|
|
||||||
/* will-change removed — only the parent card gets it on hover */
|
|
||||||
}
|
|
||||||
.cover-gradient {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.lib-badge {
|
|
||||||
position: absolute; top: var(--sp-1); right: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.card-footer {
|
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
|
||||||
padding: var(--sp-2); pointer-events: none;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
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);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.card-source {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────────────────────────────────────── */
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
|
|
||||||
/* ── Empty / error ───────────────────────────────────────────────────────── */
|
|
||||||
.empty {
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
gap: var(--sp-3); padding: var(--sp-10) var(--sp-6);
|
|
||||||
color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.retry-btn {
|
|
||||||
padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); color: var(--text-muted); cursor: pointer;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { store, setActiveDownloads } from "../../store/state.svelte";
|
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
|
||||||
|
|
||||||
let status: DownloadStatus | null = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let togglingPlay = $state(false);
|
|
||||||
let clearing = $state(false);
|
|
||||||
let dequeueing = $state(new Set<number>());
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
function applyStatus(ds: DownloadStatus) {
|
|
||||||
status = ds;
|
|
||||||
setActiveDownloads(ds.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => applyStatus(d.downloadStatus))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
|
|
||||||
|
|
||||||
async function togglePlay() {
|
|
||||||
if (togglingPlay) return;
|
|
||||||
togglingPlay = true;
|
|
||||||
const wasRunning = status?.state === "STARTED";
|
|
||||||
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
|
|
||||||
try {
|
|
||||||
if (wasRunning) {
|
|
||||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
|
||||||
applyStatus(d.stopDownloader.downloadStatus);
|
|
||||||
} else {
|
|
||||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
|
||||||
applyStatus(d.startDownloader.downloadStatus);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { togglingPlay = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clear() {
|
|
||||||
if (clearing) return;
|
|
||||||
clearing = true;
|
|
||||||
if (status) status = { ...status, queue: [] };
|
|
||||||
setActiveDownloads([]);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
|
||||||
applyStatus(d.clearDownloader.downloadStatus);
|
|
||||||
} catch (e) { console.error(e); poll(); }
|
|
||||||
finally { clearing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
if (dequeueing.has(chapterId)) return;
|
|
||||||
dequeueing = new Set(dequeueing).add(chapterId);
|
|
||||||
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
|
|
||||||
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
|
|
||||||
catch (e) { console.error(e); poll(); }
|
|
||||||
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
|
||||||
}
|
|
||||||
let queue = $derived(status?.queue ?? []);
|
|
||||||
const isRunning = $derived(status?.state === "STARTED");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Downloads</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
|
||||||
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else if isRunning}<Pause size={14} weight="fill" />
|
|
||||||
{:else}<Play size={14} weight="fill" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:loading={clearing} onclick={clear}
|
|
||||||
disabled={clearing || queue.length === 0} title="Clear queue">
|
|
||||||
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else}<Trash size={14} weight="regular" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-bar">
|
|
||||||
<div class="status-dot" class:active={isRunning}></div>
|
|
||||||
<span class="status-text">
|
|
||||||
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
|
|
||||||
</span>
|
|
||||||
<span class="status-count">{queue.length} queued</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></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)}
|
|
||||||
{@const isActive = i === 0 && isRunning}
|
|
||||||
{@const pages = item.chapter.pageCount ?? 0}
|
|
||||||
{@const done = Math.round(item.progress * pages)}
|
|
||||||
{@const manga = item.chapter.manga}
|
|
||||||
{@const isRemoving = dequeueing.has(item.chapter.id)}
|
|
||||||
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
|
||||||
{#if manga?.thumbnailUrl}
|
|
||||||
<div class="thumb">
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
|
||||||
</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}
|
|
||||||
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
|
|
||||||
{/if}
|
|
||||||
{#if isActive}
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<span class="state-label">{item.state}</span>
|
|
||||||
{#if !isActive}
|
|
||||||
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
|
||||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
|
||||||
.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); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.status-bar { 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); margin-bottom: var(--sp-4); }
|
|
||||||
.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; }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
|
||||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.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); }
|
|
||||||
.row.row-active { border-color: var(--accent-dim); }
|
|
||||||
.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); }
|
|
||||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; 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; }
|
|
||||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
|
|
||||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.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; }
|
|
||||||
.remove-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.remove-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.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); }
|
|
||||||
</style>
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { UPDATE_MANGA, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/util";
|
|
||||||
import { settings, activeSource, genreFilter, previewManga, history, addFolder, assignMangaToFolder } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceList from "../sources/SourceList.svelte";
|
|
||||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
|
||||||
import GenreDrillPage from "./GenreDrillPage.svelte";
|
|
||||||
|
|
||||||
type ExploreMode = "explore" | "sources";
|
|
||||||
let mode: ExploreMode = "explore";
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE_EXPLORE = `
|
|
||||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
|
||||||
const ROW_CAP = 25;
|
|
||||||
const GHOST_COUNT = 3;
|
|
||||||
|
|
||||||
let allManga: Manga[] = [];
|
|
||||||
let popularManga: Manga[] = [];
|
|
||||||
let sources: Source[] = [];
|
|
||||||
let genreResultsMap = new Map<string, Manga[]>();
|
|
||||||
let loadingLib = true;
|
|
||||||
let loadingPopular = true;
|
|
||||||
let loadingGenres = false;
|
|
||||||
let loadError = false;
|
|
||||||
let retryCount = 0;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
|
||||||
let abortCtrl: AbortController | null = null;
|
|
||||||
let fetchedGenresKey = "";
|
|
||||||
|
|
||||||
function frecencyScore(readAt: number, count: number): number {
|
|
||||||
return count / Math.log((Date.now() - readAt) / 3_600_000 + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: frecencyGenres = (() => {
|
|
||||||
const mangaScores = new Map<number, number>();
|
|
||||||
const mangaReadAt = new Map<number, number>();
|
|
||||||
for (const e of $history) {
|
|
||||||
mangaScores.set(e.mangaId, (mangaScores.get(e.mangaId) ?? 0) + 1);
|
|
||||||
if (e.readAt > (mangaReadAt.get(e.mangaId) ?? 0)) mangaReadAt.set(e.mangaId, e.readAt);
|
|
||||||
}
|
|
||||||
const genreWeights = new Map<string, number>();
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
for (const [mangaId, count] of mangaScores.entries()) {
|
|
||||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
|
||||||
for (const g of mangaMap.get(mangaId)?.genre ?? []) genreWeights.set(g, (genreWeights.get(g) ?? 0) + score);
|
|
||||||
}
|
|
||||||
if (genreWeights.size === 0) allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
|
||||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
return Array.from(genreWeights.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([g]) => g);
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: continueReading = (() => {
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
|
||||||
for (const e of $history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
const manga = mangaMap.get(e.mangaId);
|
|
||||||
if (!manga) continue;
|
|
||||||
result.push({ manga, chapterName: e.chapterName, progress: e.pageNumber > 0 ? Math.min(e.pageNumber / 20, 1) : 0 });
|
|
||||||
if (result.length >= 12) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: recommended = allManga.length && frecencyGenres.length ? (() => {
|
|
||||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
|
||||||
return allManga.filter((m) => m.inLibrary && !continueIds.has(m.id) && frecencyGenres.some((g) => (m.genre ?? []).includes(g))).slice(0, 20);
|
|
||||||
})() : [];
|
|
||||||
|
|
||||||
$: if (frecencyGenres.length && allManga.length) loadGenreRows();
|
|
||||||
|
|
||||||
async function loadGenreRows() {
|
|
||||||
const key = frecencyGenres.join(",");
|
|
||||||
if (fetchedGenresKey === key) return;
|
|
||||||
fetchedGenresKey = key;
|
|
||||||
loadingGenres = true;
|
|
||||||
abortCtrl?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortCtrl = ctrl;
|
|
||||||
const streamMap = new Map<string, Manga[]>();
|
|
||||||
await Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE_EXPLORE, { genre, first: 25 }, ctrl.signal)
|
|
||||||
.then((d) => d.mangas.nodes)
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
streamMap.set(genre, mangas);
|
|
||||||
genreResultsMap = new Map(streamMap);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).catch(() => {});
|
|
||||||
if (!ctrl.signal.aborted) loadingGenres = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (retryCount >= 0) loadData();
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
if (allManga.length > 0 && retryCount === 0) return;
|
|
||||||
loadingLib = true; loadingPopular = true; loadError = false;
|
|
||||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
|
||||||
if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresKey = ""; }
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then((d) => d.mangas.nodes)
|
|
||||||
).then((m) => { allManga = m; }).catch((e) => { console.error(e); loadError = true; }).finally(() => loadingLib = false);
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES).then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
|
||||||
).then(async (allSources) => {
|
|
||||||
if (!allSources.length) { loadingPopular = false; return; }
|
|
||||||
const top = getTopSources(allSources).slice(0, 2);
|
|
||||||
sources = allSources;
|
|
||||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
|
||||||
Promise.allSettled(top.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "POPULAR", page: 1, query: null })
|
|
||||||
.then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results) if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
|
||||||
})
|
|
||||||
).then((m) => popularManga = m).catch(console.error).finally(() => loadingPopular = false);
|
|
||||||
}).catch((e) => { console.error(e); loadError = true; loadingPopular = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error) },
|
|
||||||
...($settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...$settings.folders.map((f): MenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
const el = e.currentTarget as HTMLDivElement;
|
|
||||||
if (el.scrollLeft <= 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth - 1) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
el.scrollLeft += e.deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => abortCtrl?.abort());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else if $genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="heading">Explore</h1>
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab" class:active={mode === "explore"} on:click={() => mode = "explore"}>
|
|
||||||
<Compass size={11} weight="bold" /> Explore
|
|
||||||
</button>
|
|
||||||
<button class="tab" class:active={mode === "sources"} on:click={() => mode = "sources"}>
|
|
||||||
<List size={11} weight="bold" /> Sources
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:{mode === 'explore' ? 'contents' : 'none'}">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
{#if continueReading.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><BookOpen size={11} weight="bold" /> Continue Reading</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each continueReading.slice(0, ROW_CAP) as { manga, chapterName, progress }}
|
|
||||||
<button class="card" on:click={() => previewManga.set(manga)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga }; }}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="cover" loading="lazy" decoding="async" />
|
|
||||||
{#if manga.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
|
||||||
{#if progress > 0}<div class="progress-bar"><div class="progress-fill" style="width:{progress * 100}%"></div></div>{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{manga.title}</p>
|
|
||||||
{#if chapterName}<p class="subtitle">{chapterName}</p>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if recommended.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Star size={11} weight="bold" /> Recommended for You</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each recommended.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if popularManga.length > 0 || loadingPopular}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">
|
|
||||||
<Fire size={11} weight="bold" />
|
|
||||||
{sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingPopular}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else if sources.length === 0}
|
|
||||||
<div class="no-source">No sources installed. Add extensions first.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each popularManga.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each frecencyGenres as genre}
|
|
||||||
{@const items = genreResultsMap.get(genre) ?? []}
|
|
||||||
{@const isLoading = loadingGenres && items.length === 0}
|
|
||||||
{#if isLoading || items.length > 0}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">{genre}</span>
|
|
||||||
<button class="see-all" on:click={() => genreFilter.set(genre)}>See all <ArrowRight size={11} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each items.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if items.length >= ROW_CAP}
|
|
||||||
<button class="explore-more-card" on:click={() => genreFilter.set(genre)}>
|
|
||||||
<div class="explore-more-inner">
|
|
||||||
<ArrowRight size={20} weight="light" class="explore-more-icon" />
|
|
||||||
<span class="explore-more-label">Explore more</span>
|
|
||||||
<span class="explore-more-genre">{genre}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if !loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResultsMap.get(g)?.length)}
|
|
||||||
<div class="empty">
|
|
||||||
{#if loadError}
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<span class="empty-hint">Make sure the server is running, then try again.</span>
|
|
||||||
<button class="retry-btn" on:click={() => { loadingLib = true; loadingPopular = true; retryCount++; }}>Retry</button>
|
|
||||||
{:else}
|
|
||||||
<span>Nothing to explore yet</span>
|
|
||||||
<span class="empty-hint">Add manga to your library or install sources to get started.</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if mode === "sources"}<SourceList />{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#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; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-4); }
|
|
||||||
.header-left { display: flex; align-items: center; gap: var(--sp-4); }
|
|
||||||
.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; flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { 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); transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5) 0 var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.section { margin-bottom: var(--sp-6); }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); 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; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; scroll-behavior: smooth; }
|
|
||||||
.row::-webkit-scrollbar { display: none; }
|
|
||||||
.card { flex-shrink: 0; width: 110px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .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); }
|
|
||||||
.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); }
|
|
||||||
.progress-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: var(--bg-overlay); }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent-fg); border-radius: 0 2px 0 0; transition: width 0.2s ease; }
|
|
||||||
.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); }
|
|
||||||
.subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); margin-top: 2px; letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ghost-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; pointer-events: none; visibility: hidden; }
|
|
||||||
.skeleton-row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow: hidden; }
|
|
||||||
.card-skeleton { flex-shrink: 0; width: 110px; }
|
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 80%; }
|
|
||||||
.explore-more-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--bg-raised); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: border-color var(--t-base), background var(--t-base); padding: 0; }
|
|
||||||
.explore-more-card:hover { border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.explore-more-inner { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3); pointer-events: none; }
|
|
||||||
:global(.explore-more-icon) { color: var(--text-faint); transition: color var(--t-base); }
|
|
||||||
.explore-more-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); text-align: center; }
|
|
||||||
.explore-more-genre { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; text-align: center; font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
|
||||||
.no-source { display: flex; align-items: center; justify-content: center; padding: var(--sp-4) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--sp-8) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); gap: var(--sp-2); text-align: center; }
|
|
||||||
.empty-hint { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
</style>
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
|
||||||
import { store } from "../../store/state.svelte";
|
|
||||||
import type { Extension } from "../../lib/types";
|
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
|
||||||
type Panel = null | "apk" | "repos";
|
|
||||||
|
|
||||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
|
||||||
|
|
||||||
let extensions: Extension[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
let filter: Filter = $state("installed");
|
|
||||||
let search = $state("");
|
|
||||||
let working = $state(new Set<string>());
|
|
||||||
let expanded = $state(new Set<string>());
|
|
||||||
let panel: Panel = $state(null);
|
|
||||||
let externalUrl = $state("");
|
|
||||||
let installing = $state(false);
|
|
||||||
let installError: string|null = $state(null);
|
|
||||||
let installSuccess = $state(false);
|
|
||||||
let repos: string[] = $state([]);
|
|
||||||
let reposLoading = $state(false);
|
|
||||||
let newRepoUrl = $state("");
|
|
||||||
let repoError: string|null = $state(null);
|
|
||||||
let savingRepos = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.extensions.nodes).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
|
||||||
refreshing = true;
|
|
||||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
|
||||||
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepos() {
|
|
||||||
reposLoading = true;
|
|
||||||
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
|
|
||||||
catch (e) { console.error(e); } finally { reposLoading = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRepos(updated: string[]) {
|
|
||||||
savingRepos = true;
|
|
||||||
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
|
|
||||||
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepo() {
|
|
||||||
const url = newRepoUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
saveRepos([...repos, url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
|
|
||||||
|
|
||||||
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
|
|
||||||
working = new Set(working).add(pkgName);
|
|
||||||
await fn().catch(console.error);
|
|
||||||
await load();
|
|
||||||
working.delete(pkgName); working = new Set(working);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installExternal() {
|
|
||||||
const url = externalUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
|
|
||||||
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
|
|
||||||
installing = true; installError = null; installSuccess = false;
|
|
||||||
try {
|
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
|
||||||
installSuccess = true; externalUrl = "";
|
|
||||||
await load();
|
|
||||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
|
||||||
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
|
|
||||||
finally { installing = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(p: Panel) {
|
|
||||||
panel = panel === p ? null : p;
|
|
||||||
installError = null; installSuccess = false; externalUrl = "";
|
|
||||||
repoError = null; newRepoUrl = "";
|
|
||||||
if (p === "repos") loadRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
|
|
||||||
|
|
||||||
const filtered = $derived(extensions.filter((e) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
|
||||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
|
||||||
return matchSearch && matchFilter;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
|
||||||
const map = new Map<string, Extension[]>();
|
|
||||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
|
||||||
const preferredLang = store.settings.preferredExtensionLang;
|
|
||||||
return Array.from(map.entries()).map(([base, all]) => {
|
|
||||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
|
||||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
|
||||||
{ id: "installed", label: "Installed" },
|
|
||||||
{ id: "available", label: "Available" },
|
|
||||||
{ id: "updates", label: "Updates" },
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
|
||||||
const next = new Set(expanded);
|
|
||||||
next.has(base) ? next.delete(base) : next.add(base);
|
|
||||||
expanded = next;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">Extensions</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<GitBranch size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
|
||||||
<Plus size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if panel === "apk"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Install from APK URL</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="ext-row">
|
|
||||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
|
||||||
bind:value={externalUrl} disabled={installing}
|
|
||||||
oninput={() => installError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
|
|
||||||
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
|
||||||
{:else}Install{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if panel === "repos"}
|
|
||||||
<div class="ext-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Extension Repositories</span>
|
|
||||||
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if reposLoading}
|
|
||||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else}
|
|
||||||
{#if repos.length === 0}
|
|
||||||
<div class="repo-empty">No repos configured.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-list">
|
|
||||||
{#each repos as url}
|
|
||||||
<div class="repo-row">
|
|
||||||
<span class="repo-url">{url}</span>
|
|
||||||
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
|
||||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
|
||||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
|
||||||
bind:value={newRepoUrl} disabled={savingRepos}
|
|
||||||
oninput={() => repoError = null}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
|
||||||
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
|
||||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div class="tabs">
|
|
||||||
{#each FILTERS as f}
|
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{:else if groups.length === 0}
|
|
||||||
<div class="empty">No extensions found.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { base, primary, variants }}
|
|
||||||
{@const isExpanded = expanded.has(base)}
|
|
||||||
{@const hasVariants = variants.length > 0}
|
|
||||||
<div class="group">
|
|
||||||
<div class="row">
|
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
|
||||||
<div class="info">
|
|
||||||
<span class="name">{base}</span>
|
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
|
||||||
</div>
|
|
||||||
{#if working.has(primary.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if primary.hasUpdate}
|
|
||||||
<div class="row-actions">
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
</div>
|
|
||||||
{:else if primary.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
{#if hasVariants}
|
|
||||||
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
|
||||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isExpanded && hasVariants}
|
|
||||||
<div class="variants">
|
|
||||||
{#each variants as v}
|
|
||||||
<div class="variant-row">
|
|
||||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
|
||||||
<span class="variant-name">{v.name}</span>
|
|
||||||
<span class="variant-version">v{v.versionName}</span>
|
|
||||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
|
||||||
<div class="variant-actions">
|
|
||||||
{#if working.has(v.pkgName)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
{:else if v.hasUpdate}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
|
||||||
{:else if v.isInstalled}
|
|
||||||
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</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-5) var(--sp-6) var(--sp-3); 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-1); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.4; }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
|
||||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
|
||||||
.ext-row { display: flex; gap: var(--sp-2); }
|
|
||||||
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.ext-input:focus { border-color: var(--border-focus); }
|
|
||||||
.ext-input:disabled { opacity: 0.5; }
|
|
||||||
.ext-input.error { border-color: var(--color-error) !important; }
|
|
||||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
|
|
||||||
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
|
||||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
|
|
||||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
|
||||||
.repo-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
|
||||||
.controls { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; }
|
|
||||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.group { display: flex; flex-direction: column; }
|
|
||||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
|
||||||
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); 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: 2px 6px; flex-shrink: 0; }
|
|
||||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
|
||||||
.action-btn:hover { filter: brightness(1.1); }
|
|
||||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
|
|
||||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.variant-row:hover { background: var(--bg-raised); }
|
|
||||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.variant-actions { flex-shrink: 0; }
|
|
||||||
.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); }
|
|
||||||
</style>
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
|
||||||
import { thumbUrl, gql } from "../../lib/client";
|
|
||||||
import { GET_CHAPTERS } from "../../lib/queries";
|
|
||||||
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClearAll = $state(false);
|
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7);
|
|
||||||
if (d > weekAgo) return d.toLocaleDateString("en-US", { weekday: "long" });
|
|
||||||
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
|
||||||
firstChapterName: string; chapterCount: number; readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({ mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl, latestChapterId: latest.chapterId, latestChapterName: latest.chapterName, latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName, chapterCount: group.length, readAt: latest.readAt });
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
|
||||||
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: store.history);
|
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
|
||||||
|
|
||||||
const groups = $derived((() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
})());
|
|
||||||
|
|
||||||
const stats = $derived({
|
|
||||||
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
|
|
||||||
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
|
|
||||||
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
|
|
||||||
});
|
|
||||||
|
|
||||||
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
|
||||||
|
|
||||||
async function resume(session: Session) {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: any[] } }>(GET_CHAPTERS, { mangaId: session.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === session.latestChapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">History</h1>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search store.history…" bind:value={search} />
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => search = ""}>
|
|
||||||
<XIcon size={10} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
{#if confirmClearAll}
|
|
||||||
<div class="confirm-row">
|
|
||||||
<span class="confirm-label">Clear all activity?</span>
|
|
||||||
<button class="confirm-yes" onclick={doConfirmClear}>Clear</button>
|
|
||||||
<button class="confirm-no" onclick={() => confirmClearAll = false}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="clear-btn" onclick={() => confirmClearAll = true} title="Clear all activity">
|
|
||||||
<Trash size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-bar">
|
|
||||||
<span class="stat-item"><span class="stat-val">{stats.uniqueChapters}</span><span class="stat-label">chapters</span></span>
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
|
||||||
{#if store.readingStats.currentStreakDays > 0}
|
|
||||||
<span class="stat-sep"></span>
|
|
||||||
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.history.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading history yet</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { label, items } (label)}
|
|
||||||
<div class="group">
|
|
||||||
<p class="group-label">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span class="group-count">{items.length}</span>
|
|
||||||
</p>
|
|
||||||
{#each items as session (session.latestChapterId + ":" + session.readAt)}
|
|
||||||
<div class="row-wrap">
|
|
||||||
<button class="row" onclick={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" loading="lazy" decoding="async" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-badge">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<span class="manga-title">{session.mangaTitle}</span>
|
|
||||||
<span class="chapter-name">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="chapter-range">{session.firstChapterName}<span class="range-sep">→</span>{session.latestChapterName}</span>
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}<span class="page-badge">p.{session.latestPageNumber}</span>{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="time">{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={11} weight="fill" class="play-icon" />
|
|
||||||
</button>
|
|
||||||
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
|
|
||||||
<XIcon size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</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-5) var(--sp-6) var(--sp-3); 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-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 28px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear { position: absolute; right: 7px; display: flex; align-items: center; justify-content: center; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
.confirm-row { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.confirm-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.confirm-yes { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-error); background: var(--color-error-bg); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
|
|
||||||
.confirm-yes:hover { filter: brightness(1.15); }
|
|
||||||
.confirm-no { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: background var(--t-base); }
|
|
||||||
.confirm-no:hover { background: var(--bg-raised); color: var(--text-muted); }
|
|
||||||
.clear-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.stats-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
|
||||||
.stat-item { display: flex; align-items: baseline; gap: 4px; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stat-sep { width: 1px; height: 10px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-6); scrollbar-width: none; }
|
|
||||||
.list::-webkit-scrollbar { display: none; }
|
|
||||||
.group { margin-bottom: var(--sp-4); }
|
|
||||||
.group-label { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-1) var(--sp-2) var(--sp-2); }
|
|
||||||
.group-count { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); padding: 1px 5px; border-radius: var(--radius-full); letter-spacing: 0; text-transform: none; }
|
|
||||||
.row-wrap { display: flex; align-items: center; border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.row-wrap:hover { background: var(--bg-raised); }
|
|
||||||
.row-wrap:hover .row-delete { opacity: 1; }
|
|
||||||
.row { flex: 1; display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; min-width: 0; }
|
|
||||||
.row:hover :global(.play-icon) { opacity: 1; }
|
|
||||||
.row-delete { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-base), color var(--t-base), background var(--t-base); margin-right: var(--sp-1); }
|
|
||||||
.row-delete:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
|
||||||
.session-badge { position: absolute; bottom: -4px; right: -6px; background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none; }
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.manga-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-name { font-size: var(--text-sm); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
|
||||||
.chapter-range { display: flex; align-items: center; gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
|
||||||
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
|
||||||
import type { HistoryEntry } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(mins: number): string {
|
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
|
||||||
let extraManga: Manga[] = $state([]);
|
|
||||||
let loadingLibrary: boolean = $state(true);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
|
||||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => loadingLibrary = false);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[]) {
|
|
||||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
|
||||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
|
||||||
if (!missingIds.length) return;
|
|
||||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
|
||||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
|
||||||
if (valid.length) extraManga = valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const out: HistoryEntry[] = [];
|
|
||||||
for (const e of store.history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
out.push(e);
|
|
||||||
if (out.length >= 10) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const TOTAL_SLOTS = 4;
|
|
||||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
|
||||||
|
|
||||||
const resolvedSlots = $derived((() => {
|
|
||||||
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
|
||||||
const slots: HeroSlot[] = [];
|
|
||||||
const first = continueReading[0];
|
|
||||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
|
||||||
let hi = 1;
|
|
||||||
for (let i = 1; i < TOTAL_SLOTS; i++) {
|
|
||||||
const pinId = pins[i];
|
|
||||||
if (pinId != null) {
|
|
||||||
const manga = libraryManga.find(m => m.id === pinId);
|
|
||||||
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
|
|
||||||
}
|
|
||||||
const entry = continueReading[hi++];
|
|
||||||
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
|
|
||||||
}
|
|
||||||
return slots;
|
|
||||||
})());
|
|
||||||
|
|
||||||
let activeIdx = $state(0);
|
|
||||||
const activeSlot = $derived(resolvedSlots[activeIdx]);
|
|
||||||
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : "");
|
|
||||||
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
|
|
||||||
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
|
|
||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; }
|
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; }
|
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
|
||||||
if (e.key === "ArrowRight") cycleNext();
|
|
||||||
if (e.key === "ArrowLeft") cyclePrev();
|
|
||||||
}
|
|
||||||
onMount(() => {
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
let heroStageH = $state(300);
|
|
||||||
let heroChapters: Chapter[] = $state([]);
|
|
||||||
let loadingHeroChapters = $state(false);
|
|
||||||
let heroChaptersFor: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = heroMangaId;
|
|
||||||
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
|
||||||
heroChaptersFor = mangaId;
|
|
||||||
loadingHeroChapters = true;
|
|
||||||
heroChapters = [];
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
|
||||||
if (heroChaptersFor !== mangaId) return;
|
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
|
||||||
} catch { heroChapters = []; }
|
|
||||||
finally { loadingHeroChapters = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let resuming = $state(false);
|
|
||||||
|
|
||||||
async function openChapter(chapter: Chapter) {
|
|
||||||
if (!heroMangaId) return;
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
let all = heroChapters;
|
|
||||||
if (!all.length) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
}
|
|
||||||
openReader(chapter, all);
|
|
||||||
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeActive() {
|
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
|
||||||
if (!heroEntry) return;
|
|
||||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
|
||||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
|
||||||
resuming = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
|
||||||
finally { resuming = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeEntry(entry: HistoryEntry) {
|
|
||||||
try {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
|
|
||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
|
||||||
if (ch) openReader(ch, chapters);
|
|
||||||
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
|
||||||
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
|
||||||
let pickerSlotIndex: 1|2|3|null = $state(null);
|
|
||||||
let pickerSearch = $state("");
|
|
||||||
|
|
||||||
const pickerResults = $derived(pickerSearch.trim()
|
|
||||||
? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20)
|
|
||||||
: libraryManga.slice(0, 20));
|
|
||||||
|
|
||||||
function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; }
|
|
||||||
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
|
|
||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
|
||||||
|
|
||||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
|
||||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
|
||||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
|
||||||
const recentHistory = $derived(store.history.slice(0, 8));
|
|
||||||
const stats = $derived(store.readingStats);
|
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
<div class="hero-section">
|
|
||||||
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
|
|
||||||
|
|
||||||
{#if heroThumb}
|
|
||||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="hero-backdrop hero-bd-empty"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="hero-scrim"></div>
|
|
||||||
|
|
||||||
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
|
||||||
{#if heroThumb}
|
|
||||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<div class="cover-resume-hint"><Play size={18} weight="fill" /></div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="hero-details">
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-empty-title">Nothing here yet</p>
|
|
||||||
<p class="hero-empty-sub">{activeSlot.slotIndex === 0 ? "Read a manga to see it here" : "Pin a manga or keep reading to fill this slot"}</p>
|
|
||||||
{#if activeSlot.slotIndex !== 0}
|
|
||||||
<button class="hero-cta" onclick={() => openPicker(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={11} weight="fill" /> Pin manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="hero-tags">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
|
|
||||||
{:else}
|
|
||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
|
||||||
{/if}
|
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
|
||||||
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="hero-title">{heroTitle}</h2>
|
|
||||||
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
|
|
||||||
|
|
||||||
{#if heroEntry}
|
|
||||||
<p class="hero-progress">
|
|
||||||
<Clock size={10} weight="light" />
|
|
||||||
{heroEntry.chapterName}
|
|
||||||
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
|
|
||||||
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if heroManga?.description}<p class="hero-desc">{heroManga.description}</p>{/if}
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
|
||||||
{#if activeSlot?.kind === "continue"}
|
|
||||||
<button class="hero-cta" onclick={resumeActive} disabled={resuming}>
|
|
||||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
|
||||||
</button>
|
|
||||||
{:else if heroManga}
|
|
||||||
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
|
||||||
<BookOpen size={11} weight="light" /> View manga
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if activeSlot?.slotIndex !== 0}
|
|
||||||
{#if activeSlot?.kind === "pinned"}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => unpinSlot(activeSlot.slotIndex as 1|2|3)}>
|
|
||||||
<XIcon size={10} weight="bold" /> Unpin
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="hero-cta-ghost" onclick={() => openPicker(activeSlot!.slotIndex as 1|2|3)}>
|
|
||||||
<PushPin size={10} weight="light" /> Pin
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="hero-nav-row">
|
|
||||||
<button class="hero-nav-btn" onclick={cyclePrev} aria-label="Previous"><ArrowLeft size={12} weight="bold" /></button>
|
|
||||||
<div class="hero-dots">
|
|
||||||
{#each resolvedSlots as slot, i}
|
|
||||||
<button class="hero-dot" class:active={activeIdx === i} class:pinned={slot.kind === "pinned"} onclick={() => goToSlot(i)} aria-label="Slot {i + 1}"></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="hero-nav-btn" onclick={cycleNext} aria-label="Next"><ArrowRight size={12} weight="bold" /></button>
|
|
||||||
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-chapters">
|
|
||||||
<div class="hero-chapters-header"><ListBullets size={11} weight="bold" /><span>Up Next</span></div>
|
|
||||||
|
|
||||||
{#if activeSlot?.kind === "empty"}
|
|
||||||
<p class="hero-chapters-empty">No chapters to show</p>
|
|
||||||
{:else if loadingHeroChapters}
|
|
||||||
{#each Array(4) as _}
|
|
||||||
<div class="chapter-row-sk">
|
|
||||||
<div class="sk sk-num"></div>
|
|
||||||
<div class="sk-info"><div class="sk sk-name"></div><div class="sk sk-meta"></div></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if heroChapters.length === 0}
|
|
||||||
<p class="hero-chapters-empty">No chapters available</p>
|
|
||||||
{:else}
|
|
||||||
{#each heroChapters as ch (ch.id)}
|
|
||||||
{@const isCurrent = heroEntry?.chapterId === ch.id}
|
|
||||||
<button class="chapter-row" class:chapter-row-current={isCurrent} class:chapter-row-read={ch.isRead && !isCurrent} onclick={() => openChapter(ch)}>
|
|
||||||
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
|
|
||||||
<div class="ch-info">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
|
|
||||||
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
|
|
||||||
{:else if ch.isRead}
|
|
||||||
<span class="ch-meta ch-read">Read</span>
|
|
||||||
{:else if ch.uploadDate}
|
|
||||||
<span class="ch-meta">{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate)*1000).toLocaleDateString("en-US",{month:"short",day:"numeric"})}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if heroManga}
|
|
||||||
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
|
|
||||||
All chapters <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="activity-list">
|
|
||||||
{#if recentHistory.length > 0}
|
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
|
||||||
<button class="activity-row" onclick={() => resumeEntry(entry)}>
|
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" />
|
|
||||||
<div class="activity-info">
|
|
||||||
<span class="activity-title">{entry.mangaTitle}</span>
|
|
||||||
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
|
|
||||||
</div>
|
|
||||||
<span class="activity-time">{timeAgo(entry.readAt)}</span>
|
|
||||||
<span class="activity-play"><Play size={10} weight="fill" /></span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<div class="activity-placeholder">
|
|
||||||
{#each Array(5) as _, i}
|
|
||||||
<div class="activity-row activity-row-sk">
|
|
||||||
<div class="sk-thumb"></div>
|
|
||||||
<div class="activity-info">
|
|
||||||
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
|
|
||||||
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="sk sk-time"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div class="activity-placeholder-overlay">
|
|
||||||
<button class="activity-placeholder-cta" onclick={() => setNavPage("library")}>
|
|
||||||
<BookOpen size={12} weight="light" /> Start reading
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
|
||||||
{#if completedManga.length > 0}
|
|
||||||
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if completedManga.length > 0}
|
|
||||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
|
||||||
{#each completedManga as m (m.id)}
|
|
||||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
|
||||||
<div class="mini-cover-wrap">
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
|
||||||
<div class="mini-gradient"></div>
|
|
||||||
<div class="mini-footer">
|
|
||||||
<p class="mini-card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-divider"></div>
|
|
||||||
|
|
||||||
<div class="bottom-col">
|
|
||||||
<div class="bottom-section-hd">
|
|
||||||
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
|
||||||
</div>
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div><div class="stat-body"><span class="stat-val">{stats.currentStreakDays}</span><span class="stat-label">Day streak</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
|
|
||||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pickerOpen}
|
|
||||||
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
|
|
||||||
<div class="picker-modal">
|
|
||||||
<div class="picker-header">
|
|
||||||
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
|
|
||||||
<button class="picker-close" onclick={closePicker}><XIcon size={13} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
|
|
||||||
</div>
|
|
||||||
<div class="picker-list">
|
|
||||||
{#if loadingLibrary}
|
|
||||||
<p class="picker-empty">Loading…</p>
|
|
||||||
{:else if pickerResults.length === 0}
|
|
||||||
<p class="picker-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each pickerResults as m (m.id)}
|
|
||||||
<button class="picker-row" onclick={() => pinManga(m)}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" />
|
|
||||||
<div class="picker-info">
|
|
||||||
<span class="picker-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
|
||||||
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
|
|
||||||
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
|
||||||
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
|
||||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
|
||||||
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
|
|
||||||
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
|
|
||||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
|
|
||||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
|
||||||
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
|
|
||||||
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
|
||||||
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
|
||||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
|
||||||
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
|
||||||
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
|
||||||
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
|
|
||||||
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
|
||||||
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
|
||||||
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
|
||||||
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
|
||||||
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
|
|
||||||
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
|
||||||
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
|
||||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
|
||||||
.hero-cta { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta:hover:not(:disabled) { filter: brightness(1.15); }
|
|
||||||
.hero-cta:disabled { opacity: 0.55; cursor: default; }
|
|
||||||
.hero-cta-ghost { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 14px; border-radius: var(--radius-full); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.13); color: rgba(255,255,255,0.52); cursor: pointer; transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.hero-cta-ghost:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.82); }
|
|
||||||
.hero-nav-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; padding-top: var(--sp-2); border-top: 1px solid rgba(255,255,255,0.08); }
|
|
||||||
.hero-nav-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); color: rgba(255,255,255,0.6); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.hero-nav-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
|
|
||||||
.hero-dots { display: flex; gap: 5px; align-items: center; }
|
|
||||||
.hero-dot { width: 5px; height: 5px; border-radius: 50%; background: rgba(255,255,255,0.22); border: none; cursor: pointer; padding: 0; transition: background var(--t-base), transform var(--t-base); }
|
|
||||||
.hero-dot:hover { background: rgba(255,255,255,0.5); }
|
|
||||||
.hero-dot.active { background: #fff; transform: scale(1.35); }
|
|
||||||
.hero-dot.pinned { background: rgba(168,132,232,0.55); }
|
|
||||||
.hero-dot.pinned.active { background: #c4a8f0; }
|
|
||||||
.hero-counter { font-family: var(--font-ui); font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); margin-left: auto; }
|
|
||||||
.hero-chapters { position: relative; z-index: 2; width: clamp(180px, 32%, 240px); flex-shrink: 0; display: flex; flex-direction: column; padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden; }
|
|
||||||
.hero-chapters-header { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.4); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding-bottom: var(--sp-2); margin-bottom: var(--sp-1); border-bottom: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; }
|
|
||||||
.hero-chapters-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.25); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0; }
|
|
||||||
.chapter-row { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.chapter-row:hover { background: rgba(255,255,255,0.07); }
|
|
||||||
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
|
|
||||||
.ch-num { font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px; }
|
|
||||||
.chapter-row-current .ch-num { color: var(--accent-fg); }
|
|
||||||
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.75); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.chapter-row-read .ch-name { color: rgba(255,255,255,0.35); }
|
|
||||||
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
|
|
||||||
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-read { color: rgba(255,255,255,0.2); }
|
|
||||||
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
|
|
||||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
|
|
||||||
.sk-name { height: 11px; width: 85%; }
|
|
||||||
.sk-meta { height: 9px; width: 50%; }
|
|
||||||
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
|
||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
|
||||||
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
|
||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
|
||||||
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
|
||||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-4); padding-bottom: var(--sp-5); }
|
|
||||||
.bottom-col:first-child { padding-right: var(--sp-4); }
|
|
||||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
|
||||||
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
|
|
||||||
|
|
||||||
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.mini-card:hover { will-change: transform; }
|
|
||||||
.mini-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); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
|
||||||
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
|
||||||
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.mini-card-title { 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); }
|
|
||||||
.mini-card-source { 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; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
|
||||||
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-3); }
|
|
||||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
|
||||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
|
||||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
|
||||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
|
||||||
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
|
||||||
.activity-row-sk { cursor: default; pointer-events: none; }
|
|
||||||
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
|
|
||||||
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); }
|
|
||||||
.sk-title { height: 11px; margin-bottom: 5px; }
|
|
||||||
.sk-sub { height: 9px; }
|
|
||||||
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
|
|
||||||
.activity-placeholder { position: relative; }
|
|
||||||
.activity-placeholder-overlay { position: absolute; left: 0; right: 0; top: 0; bottom: -1px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4); pointer-events: none; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%); }
|
|
||||||
.activity-placeholder-cta { pointer-events: all; display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.65); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.activity-placeholder-cta:hover { background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.9); }
|
|
||||||
.picker-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.picker-modal { width: min(460px, calc(100vw - 48px)); max-height: 68vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.picker-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; }
|
|
||||||
.picker-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.picker-search-wrap { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.picker-search { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); }
|
|
||||||
.picker-search::placeholder { color: var(--text-faint); }
|
|
||||||
.picker-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.picker-list::-webkit-scrollbar { display: none; }
|
|
||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
|
||||||
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.7 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
|
||||||
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
|
||||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
|
|
||||||
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
|
||||||
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let error: string|null = $state(null);
|
|
||||||
let retryCount: number = $state(0);
|
|
||||||
let search: string = $state("");
|
|
||||||
let renderVisible: number = $state(0);
|
|
||||||
let scrollEl: HTMLDivElement;
|
|
||||||
let containerWidth: number = $state(800);
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
});
|
|
||||||
|
|
||||||
function fetchLibrary() {
|
|
||||||
return cache.get(
|
|
||||||
CACHE_KEYS.LIBRARY,
|
|
||||||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
|
||||||
DEFAULT_TTL_MS,
|
|
||||||
CACHE_GROUPS.LIBRARY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadData() {
|
|
||||||
fetchLibrary()
|
|
||||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
|
||||||
.catch(e => error = e.message)
|
|
||||||
.finally(() => loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
retryCount;
|
|
||||||
loading = true; error = null;
|
|
||||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
untrack(() => loadData());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
|
|
||||||
$effect(() => {
|
|
||||||
const allIds = new Set(allManga.map(m => m.id));
|
|
||||||
const missingIds = store.settings.folders
|
|
||||||
.flatMap(f => f.mangaIds)
|
|
||||||
.filter(id => !allIds.has(id));
|
|
||||||
if (!missingIds.length) return;
|
|
||||||
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
|
|
||||||
if (!toFetch.length) return;
|
|
||||||
untrack(() => {
|
|
||||||
Promise.all(
|
|
||||||
toFetch.map(id =>
|
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () =>
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
|
|
||||||
).catch(() => null)
|
|
||||||
)
|
|
||||||
).then(results => {
|
|
||||||
const valid = results.filter(Boolean) as Manga[];
|
|
||||||
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
|
||||||
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
|
||||||
});
|
|
||||||
|
|
||||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
|
||||||
|
|
||||||
// All manga available for folder filtering — library + any extras fetched above
|
|
||||||
const folderPool = $derived((() => {
|
|
||||||
const seen = new Set(allManga.map(m => m.id));
|
|
||||||
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
|
||||||
})());
|
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (store.libraryFilter === "library") {
|
|
||||||
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
|
||||||
}
|
|
||||||
if (store.libraryFilter === "downloaded") {
|
|
||||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
|
||||||
}
|
|
||||||
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
|
||||||
if (folder) {
|
|
||||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
})());
|
|
||||||
|
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
|
||||||
const visibleManga = $derived(filtered.slice(0, renderVisible));
|
|
||||||
const hasMore = $derived(filtered.length > renderVisible);
|
|
||||||
const remainingCount = $derived(filtered.length - renderVisible);
|
|
||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
|
||||||
|
|
||||||
const counts = $derived({
|
|
||||||
library: allManga.length,
|
|
||||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
allManga = allManga.filter(m => m.id !== manga.id);
|
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
|
||||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
const mangaFolders = getMangaFolders(m.id);
|
|
||||||
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
|
||||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
|
||||||
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
|
||||||
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
|
||||||
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmptyCtx(): MenuEntry[] {
|
|
||||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
|
||||||
ro.observe(scrollEl);
|
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
|
||||||
return () => { ro.disconnect(); unsub(); };
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="root"
|
|
||||||
role="presentation"
|
|
||||||
bind:this={scrollEl}
|
|
||||||
oncontextmenu={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
emptyCtx = { x: e.clientX, y: e.clientY };
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if store.settings.libraryBranches ?? true}
|
|
||||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
|
||||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
|
||||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
|
||||||
<path d="M270 220 C255 190 230 175 210 150"/>
|
|
||||||
<path d="M270 220 C290 195 310 185 330 165"/>
|
|
||||||
<path d="M310 400 C290 375 265 368 245 350"/>
|
|
||||||
<path d="M310 400 C330 370 355 362 370 340"/>
|
|
||||||
<path d="M210 150 C195 128 185 108 175 80"/>
|
|
||||||
<path d="M210 150 C225 130 240 122 258 105"/>
|
|
||||||
<path d="M245 350 C228 330 215 315 205 290"/>
|
|
||||||
<path d="M175 80 C168 60 162 42 158 20"/>
|
|
||||||
<path d="M175 80 C185 62 195 50 208 35"/>
|
|
||||||
<path d="M205 290 C196 268 190 250 186 225"/>
|
|
||||||
<path d="M258 105 C268 88 278 72 292 52"/>
|
|
||||||
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
|
|
||||||
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="center">
|
|
||||||
<p class="error-msg">Could not reach Suwayomi</p>
|
|
||||||
<p class="error-detail">Make sure the server is running, then retry.</p>
|
|
||||||
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<span class="heading">Library</span>
|
|
||||||
<div class="tabs">
|
|
||||||
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
|
||||||
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
|
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
|
||||||
{label}
|
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
|
||||||
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{folder.name}
|
|
||||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search" bind:value={search} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="grid">
|
|
||||||
{#each Array(12) as _}
|
|
||||||
<div class="card-skeleton">
|
|
||||||
<div class="cover-skeleton skeleton"></div>
|
|
||||||
<div class="title-skeleton skeleton"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if filtered.length === 0}
|
|
||||||
<div class="center">
|
|
||||||
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
|
||||||
: store.libraryFilter === "downloaded" ? "No downloaded manga."
|
|
||||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid" style="--cols:{cols}">
|
|
||||||
{#each visibleManga as m (m.id)}
|
|
||||||
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
|
||||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
|
||||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if hasMore}
|
|
||||||
<div class="load-more-row">
|
|
||||||
<button class="load-more-btn" onclick={loadMore}>
|
|
||||||
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
|
|
||||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
{#if emptyCtx}
|
|
||||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { position: relative; padding: var(--sp-5) var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
|
||||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
|
||||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
|
||||||
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); gap: var(--sp-4); flex-wrap: wrap; }
|
|
||||||
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { 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); }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card:hover .cover { filter: brightness(1.07); }
|
|
||||||
.card:hover .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); }
|
|
||||||
.cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; }
|
|
||||||
.badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
|
|
||||||
.badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
|
||||||
.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: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
|
||||||
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
|
|
||||||
.load-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: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
|
|
||||||
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
|
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, untrack } from "svelte";
|
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
|
||||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const CHAPTER_TTL_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
|
||||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingManga: boolean = $state(false);
|
|
||||||
let loadingChapters: boolean = $state(true);
|
|
||||||
let enqueueing: Set<number> = $state(new Set());
|
|
||||||
let dlOpen: boolean = $state(false);
|
|
||||||
let detailsOpen: boolean = $state(false);
|
|
||||||
let togglingLibrary: boolean = $state(false);
|
|
||||||
let chapterPage: number = $state(1);
|
|
||||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
|
||||||
let jumpOpen: boolean = $state(false);
|
|
||||||
let jumpInput: string = $state("");
|
|
||||||
let viewMode: "list" | "grid" = $state("list");
|
|
||||||
let deletingAll: boolean = $state(false);
|
|
||||||
let refreshing: boolean = $state(false);
|
|
||||||
let descExpanded: boolean = $state(false);
|
|
||||||
let genresExpanded: boolean = $state(false);
|
|
||||||
let folderPickerOpen: boolean = $state(false);
|
|
||||||
let folderCreating: boolean = $state(false);
|
|
||||||
let folderNewName: string = $state("");
|
|
||||||
let rangeFrom: string = $state("");
|
|
||||||
let rangeTo: string = $state("");
|
|
||||||
let showRange: boolean = $state(false);
|
|
||||||
let migrateOpen: boolean = $state(false);
|
|
||||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
|
||||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
let loadingFor: number | null = null;
|
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
const n = Number(ts);
|
|
||||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
|
||||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = $derived(store.settings.chapterSortDir);
|
|
||||||
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
|
||||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
|
||||||
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
|
|
||||||
|
|
||||||
const continueChapter = $derived((() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
const anyRead = asc.some(c => c.isRead);
|
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
|
|
||||||
return { chapter: asc[0], type: "reread" as const };
|
|
||||||
})());
|
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
|
||||||
|
|
||||||
function loadManga(id: number) {
|
|
||||||
mangaAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
mangaAbort = ctrl;
|
|
||||||
loadingFor = id;
|
|
||||||
const cached = mangaStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
manga = cached.data; loadingManga = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingManga = true;
|
|
||||||
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
|
|
||||||
manga = d.manga;
|
|
||||||
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
|
|
||||||
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChapters(id: number) {
|
|
||||||
chapterAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
chapterAbort = ctrl;
|
|
||||||
const cached = chapterStore.get(id);
|
|
||||||
if (cached) {
|
|
||||||
applyChapters(cached.data); loadingChapters = false;
|
|
||||||
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chapters = []; loadingChapters = true;
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
applyChapters(d.chapters.nodes); loadingChapters = false;
|
|
||||||
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
|
|
||||||
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
|
|
||||||
.then(fresh => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return;
|
|
||||||
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(fresh.chapters.nodes);
|
|
||||||
});
|
|
||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const m = store.activeManga;
|
|
||||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
|
||||||
});
|
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const wasOpen = prevChapterId !== null;
|
|
||||||
prevChapterId = store.activeChapter?.id ?? null;
|
|
||||||
if (wasOpen && !store.activeChapter && store.activeManga) {
|
|
||||||
const id = store.activeManga.id;
|
|
||||||
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLibrary = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLibrary = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadChapters(id: number) {
|
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
|
|
||||||
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
|
|
||||||
applyChapters(d.chapters.nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
enqueueing = new Set(enqueueing).add(ch.id);
|
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
|
||||||
if (!chapterIds.length) return;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
|
||||||
const idSet = new Set(ids);
|
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
|
|
||||||
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
|
|
||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
|
||||||
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
deletingAll = true;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
|
||||||
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
|
||||||
deletingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChapters() {
|
|
||||||
if (!store.activeManga || refreshing) return;
|
|
||||||
refreshing = true;
|
|
||||||
chapterStore.delete(store.activeManga.id);
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
|
|
||||||
.then(() => reloadChapters(store.activeManga!.id))
|
|
||||||
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
|
||||||
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
|
||||||
.finally(() => refreshing = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
|
||||||
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
|
|
||||||
return [
|
|
||||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: idx === 0 || above.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: idx === 0 || above.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
|
||||||
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
|
|
||||||
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleDlOutside, true);
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
|
|
||||||
else document.removeEventListener("mousedown", handleFolderOutside, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
function enqueueNext(n: number) {
|
|
||||||
if (!continueChapter) return;
|
|
||||||
const idx = sortedChapters.indexOf(continueChapter.chapter);
|
|
||||||
if (idx < 0) return;
|
|
||||||
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueRange() {
|
|
||||||
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo);
|
|
||||||
if (isNaN(from) || isNaN(to)) return;
|
|
||||||
const lo = Math.min(from, to), hi = Math.max(from, to);
|
|
||||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFolder() {
|
|
||||||
const name = folderNewName.trim();
|
|
||||||
if (!name || !store.activeManga) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, store.activeManga.id);
|
|
||||||
folderNewName = ""; folderCreating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeManga}
|
|
||||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
|
||||||
|
|
||||||
<div class="sidebar">
|
|
||||||
<button class="back" onclick={() => setActiveManga(null)}>
|
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingManga}
|
|
||||||
<div class="meta-skeleton">
|
|
||||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
|
||||||
<div class="skeleton sk-line" style="width:60%;height:11px"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="meta">
|
|
||||||
<p class="title">{manga?.title}</p>
|
|
||||||
{#if manga?.author || manga?.artist}
|
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
{#if statusLabel}
|
|
||||||
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
|
||||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
{#if manga.genre.length > 5}
|
|
||||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
|
||||||
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if manga?.description}
|
|
||||||
<div class="desc-wrap">
|
|
||||||
<p class="desc" class:expanded={descExpanded}>{manga.description}</p>
|
|
||||||
{#if manga.description.length > 120}
|
|
||||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? "Less" : "More"}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if totalCount > 0}
|
|
||||||
<div class="progress-section">
|
|
||||||
<div class="progress-header">
|
|
||||||
<span class="progress-label">{readCount} / {totalCount} read</span>
|
|
||||||
<span class="progress-pct">{Math.round(progressPct)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
|
||||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
|
||||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
|
||||||
</button>
|
|
||||||
{#if manga?.realUrl}
|
|
||||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
|
||||||
<ArrowSquareOut size={13} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if continueChapter}
|
|
||||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
|
||||||
<Play size={12} weight="fill" />
|
|
||||||
{continueChapter.type === "continue"
|
|
||||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
|
||||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
|
|
||||||
|
|
||||||
{#if !loadingManga && manga?.source}
|
|
||||||
<div class="details-section">
|
|
||||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
|
||||||
<span>Details</span>
|
|
||||||
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{#if detailsOpen}
|
|
||||||
<div class="details-body">
|
|
||||||
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
|
|
||||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
|
||||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
|
||||||
<button class="migrate-btn" onclick={() => migrateOpen = true}>
|
|
||||||
<ArrowsClockwise size={12} weight="light" /> Switch source
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<button class="delete-all-btn" onclick={deleteAllDownloads} disabled={deletingAll}>
|
|
||||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="list-wrap">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="list-header-left">
|
|
||||||
<button class="sort-btn" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; }}>
|
|
||||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
|
||||||
{sortDir === "desc" ? "Newest first" : "Oldest first"}
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"}>
|
|
||||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="list-header-right">
|
|
||||||
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
|
||||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
{#if folderPickerOpen}
|
|
||||||
<div class="fp-menu">
|
|
||||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
|
||||||
<p class="fp-empty">No folders yet</p>
|
|
||||||
{/if}
|
|
||||||
{#each store.settings.folders as folder}
|
|
||||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
|
||||||
<button class="fp-item" class:fp-item-active={isIn}
|
|
||||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
|
||||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="fp-div"></div>
|
|
||||||
{#if folderCreating}
|
|
||||||
<div class="fp-create">
|
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
|
|
||||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if chapters.length > 1}
|
|
||||||
<div class="jump-wrap">
|
|
||||||
{#if !jumpOpen}
|
|
||||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
|
||||||
{:else}
|
|
||||||
<div class="jump-row">
|
|
||||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
const num = parseFloat(jumpInput);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
const target = sortedChapters.find(c => c.chapterNumber === num)
|
|
||||||
?? sortedChapters.reduce((best, c) => Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best, sortedChapters[0]);
|
|
||||||
if (target) openReader(target, sortedChapters);
|
|
||||||
}
|
|
||||||
jumpOpen = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button class="jump-cancel" onclick={() => jumpOpen = false}>✕</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if chapters.length > 0}
|
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
|
||||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
{#if dlOpen}
|
|
||||||
<div class="dl-dropdown">
|
|
||||||
{#if continueChapter}
|
|
||||||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
|
||||||
{#if contIdx >= 0}
|
|
||||||
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
|
|
||||||
<div class="dl-next-row">
|
|
||||||
{#each [5, 10, 25] as n}
|
|
||||||
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
|
|
||||||
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { enqueueNext(n); dlOpen = false; }}>
|
|
||||||
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if !showRange}
|
|
||||||
<button class="dl-item" onclick={() => showRange = true}>
|
|
||||||
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="dl-range-row">
|
|
||||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
|
||||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
|
|
||||||
<span class="dl-range-sep">–</span>
|
|
||||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
|
||||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button class="dl-item" onclick={() => { enqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
|
|
||||||
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{#if downloadedCount > 0}
|
|
||||||
<div class="dl-divider"></div>
|
|
||||||
<button class="dl-item dl-item-danger" onclick={() => { deleteAllDownloads(); dlOpen = false; }} disabled={deletingAll}>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span class="dl-item-sub">{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>←</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>→</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
|
|
||||||
{#if loadingChapters && chapters.length === 0}
|
|
||||||
{#if viewMode === "grid"}
|
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
|
||||||
{:else}
|
|
||||||
{#each Array(8) as _}<div class="row-skeleton"><div class="skeleton sk-line" style="width:55%;height:12px"></div><div class="skeleton sk-line" style="width:25%;height:11px"></div></div>{/each}
|
|
||||||
{/if}
|
|
||||||
{:else if viewMode === "grid"}
|
|
||||||
{#each sortedChapters as ch, i}
|
|
||||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
|
||||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:bookmarked={ch.isBookmarked}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
|
||||||
title={ch.name}>
|
|
||||||
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
|
|
||||||
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
|
|
||||||
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each pageChapters as ch}
|
|
||||||
{@const idxInSorted = sortedChapters.indexOf(ch)}
|
|
||||||
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
|
|
||||||
onclick={() => openReader(ch, sortedChapters)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
|
|
||||||
<div class="ch-left">
|
|
||||||
<span class="ch-name">{ch.name}</span>
|
|
||||||
<div class="ch-meta">
|
|
||||||
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
|
|
||||||
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
|
|
||||||
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ch-right">
|
|
||||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
|
||||||
{#if ch.isDownloaded}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
|
||||||
{:else if enqueueing.has(ch.id)}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
|
||||||
{:else}
|
|
||||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination-bottom">
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>Next →</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if migrateOpen && manga}
|
|
||||||
<MigrateModal
|
|
||||||
{manga}
|
|
||||||
currentChapters={chapters}
|
|
||||||
onClose={() => migrateOpen = false}
|
|
||||||
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
.sidebar { width: 200px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
|
||||||
.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; transition: color var(--t-base); }
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.sk-line { border-radius: var(--radius-sm); }
|
|
||||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
||||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: var(--leading-snug); letter-spacing: var(--tracking-tight); }
|
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
|
||||||
.status-badge { display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content; }
|
|
||||||
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.genre { font-size: var(--text-2xs); font-family: var(--font-ui); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
|
||||||
.desc-wrap { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.desc.expanded { -webkit-line-clamp: unset; display: block; overflow: visible; }
|
|
||||||
.desc-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
|
|
||||||
.desc-toggle:hover { opacity: 1; }
|
|
||||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
|
||||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
|
||||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
|
||||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
|
||||||
.details-toggle:hover { color: var(--text-muted); }
|
|
||||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
|
||||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
|
||||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
|
||||||
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
|
||||||
.delete-all-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.delete-all-btn:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.delete-all-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
.list-header { 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); flex-wrap: wrap; }
|
|
||||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.sort-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.fp-wrap { position: relative; }
|
|
||||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.fp-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.fp-item:hover { background: var(--bg-overlay); }
|
|
||||||
.fp-item.fp-item-active { color: var(--accent-fg); }
|
|
||||||
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
|
|
||||||
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
|
|
||||||
.fp-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0; }
|
|
||||||
.fp-input:focus { border-color: var(--border-focus); }
|
|
||||||
.fp-confirm { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
|
||||||
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.fp-cancel { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
|
||||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
|
||||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
|
||||||
.jump-wrap { position: relative; }
|
|
||||||
.jump-toggle { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.jump-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.jump-row { display: flex; align-items: center; gap: 4px; }
|
|
||||||
.jump-input { width: 64px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; }
|
|
||||||
.jump-input:focus { border-color: var(--border-focus); }
|
|
||||||
.jump-cancel { font-size: 12px; color: var(--text-faint); padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
|
||||||
.jump-cancel:hover { color: var(--text-muted); }
|
|
||||||
.dl-wrap { position: relative; }
|
|
||||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
|
|
||||||
.dl-next-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
|
|
||||||
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
|
||||||
.dl-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dl-item:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-item.dl-item-danger { color: var(--color-error); }
|
|
||||||
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
|
|
||||||
.dl-range-back { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer; }
|
|
||||||
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.dl-range-input { flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; text-align: center; }
|
|
||||||
.dl-range-input:focus { border-color: var(--border-focus); }
|
|
||||||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
|
||||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
|
||||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-list { flex: 1; overflow-y: auto; }
|
|
||||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
|
||||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
|
||||||
.ch-row:hover { background: var(--bg-raised); }
|
|
||||||
.ch-row.read { opacity: 0.45; }
|
|
||||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
:global(.read-icon) { color: var(--text-faint); }
|
|
||||||
:global(.enqueue-icon) { color: var(--text-faint); }
|
|
||||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
|
||||||
.ch-row:hover .dl-btn { opacity: 1; }
|
|
||||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
|
||||||
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
|
||||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
|
||||||
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.grid-cell-num { font-size: 10px; }
|
|
||||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
|
||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -1,705 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, tick, untrack } from "svelte";
|
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
|
||||||
import type { FitMode } from "../../store/state.svelte";
|
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
|
||||||
const cacheOrder: number[] = [];
|
|
||||||
const MAX_CACHED = 10;
|
|
||||||
|
|
||||||
function cacheTouch(id: number) {
|
|
||||||
const i = cacheOrder.indexOf(id);
|
|
||||||
if (i !== -1) cacheOrder.splice(i, 1);
|
|
||||||
cacheOrder.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheEvict(keep: Set<number>) {
|
|
||||||
while (pageCache.size > MAX_CACHED) {
|
|
||||||
const victim = cacheOrder.find(id => !keep.has(id));
|
|
||||||
if (!victim) break;
|
|
||||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
|
||||||
pageCache.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
|
||||||
const cached = pageCache.get(chapterId);
|
|
||||||
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
|
|
||||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
if (!inflight.has(chapterId)) {
|
|
||||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
|
||||||
.then(d => { const urls = d.fetchChapterPages.pages.map(thumbUrl); pageCache.set(chapterId, urls); cacheTouch(chapterId); 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const aspectCache = new Map<string, number>();
|
|
||||||
function preloadImage(url: string) { new Image().src = url; }
|
|
||||||
|
|
||||||
function decodeImage(url: string): Promise<void> {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
|
||||||
img.onerror = () => resolve();
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureAspect(url: string): Promise<number> {
|
|
||||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
|
||||||
return 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 = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let sentinelEl: HTMLDivElement;
|
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
let dlOpen = $state(false);
|
|
||||||
let zoomOpen = $state(false);
|
|
||||||
let uiVisible = $state(true);
|
|
||||||
let pageReady = $state(false);
|
|
||||||
let pageGroups: number[][] = $state([]);
|
|
||||||
let stripChapters: StripChapter[] = $state([]);
|
|
||||||
let visibleChapterId: number | null = $state(null);
|
|
||||||
let nextN = $state(5);
|
|
||||||
let dlBusy = $state(false);
|
|
||||||
let markedRead = new Set<number>();
|
|
||||||
let appended = new Set<number>();
|
|
||||||
let appending = false;
|
|
||||||
let abortCtrl: AbortController | null = null;
|
|
||||||
let loadingId: number | null = null;
|
|
||||||
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
|
||||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
|
||||||
const style = $derived(store.settings.pageStyle ?? "single");
|
|
||||||
const maxW = $derived(store.settings.maxPageWidth ?? 900);
|
|
||||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
|
||||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
|
||||||
|
|
||||||
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
|
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
|
||||||
: store.activeChapter);
|
|
||||||
|
|
||||||
const adjacent = $derived((() => {
|
|
||||||
const ref = displayChapter ?? store.activeChapter;
|
|
||||||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
|
||||||
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
|
||||||
return {
|
|
||||||
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
|
||||||
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
|
||||||
remaining: store.activeChapterList.slice(idx + 1),
|
|
||||||
};
|
|
||||||
})());
|
|
||||||
|
|
||||||
const visibleChunkLastPage = $derived((() => {
|
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
|
||||||
const chId = visibleChapterId ?? store.activeChapter?.id;
|
|
||||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
|
||||||
return chunk?.urls.length ?? lastPage;
|
|
||||||
})());
|
|
||||||
|
|
||||||
const imgCls = $derived([
|
|
||||||
"img",
|
|
||||||
fit === "width" && "fit-width",
|
|
||||||
fit === "height" && "fit-height",
|
|
||||||
fit === "screen" && "fit-screen",
|
|
||||||
fit === "original" && "fit-original",
|
|
||||||
store.settings.optimizeContrast && "optimize-contrast",
|
|
||||||
].filter(Boolean).join(" "));
|
|
||||||
|
|
||||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
|
||||||
const styleLabel = $derived(style);
|
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
|
||||||
const ch = store.activeChapter;
|
|
||||||
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
|
|
||||||
markedRead.add(ch.id);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
|
|
||||||
.then(() => {
|
|
||||||
if (store.activeManga) {
|
|
||||||
const updated = store.activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
|
|
||||||
checkAndMarkCompleted(store.activeManga.id, updated);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => { markedRead.delete(ch.id); console.error(e); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUi() {
|
|
||||||
uiVisible = true;
|
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
|
||||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const ch = store.activeChapter;
|
|
||||||
if (ch) untrack(() => loadChapter(ch.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadChapter(id: number) {
|
|
||||||
abortCtrl?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortCtrl = ctrl;
|
|
||||||
loadingId = id;
|
|
||||||
appended = new Set([id]);
|
|
||||||
appending = false;
|
|
||||||
markedRead = new Set();
|
|
||||||
aspectCache.clear();
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
pageGroups = [];
|
|
||||||
pageReady = false;
|
|
||||||
stripChapters = [];
|
|
||||||
visibleChapterId = null;
|
|
||||||
store.pageUrls = [];
|
|
||||||
store.pageNumber = 1;
|
|
||||||
try {
|
|
||||||
const urls = await fetchPages(id, ctrl.signal);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
store.pageUrls = urls;
|
|
||||||
pageReady = true;
|
|
||||||
if (style === "longstrip" && autoNext) {
|
|
||||||
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
|
|
||||||
visibleChapterId = id;
|
|
||||||
}
|
|
||||||
loading = false;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendNextChapter() {
|
|
||||||
if (appending) return;
|
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
|
||||||
if (!lastChunk) return;
|
|
||||||
const list = store.activeChapterList;
|
|
||||||
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
|
||||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
|
||||||
const next = list[lastIdx + 1];
|
|
||||||
if (!next || appended.has(next.id)) return;
|
|
||||||
appended.add(next.id);
|
|
||||||
appending = true;
|
|
||||||
fetchPages(next.id)
|
|
||||||
.then(urls => { urls.forEach(url => measureAspect(url).catch(() => {})); urls.slice(0, 6).forEach(preloadImage); return urls; })
|
|
||||||
.then(urls => {
|
|
||||||
if (stripChapters.some(c => c.chapterId === next.id)) return;
|
|
||||||
const last = stripChapters[stripChapters.length - 1];
|
|
||||||
const start = last ? last.startGlobalIdx + last.urls.length : 0;
|
|
||||||
const MAX_STRIP = 8;
|
|
||||||
if (stripChapters.length >= MAX_STRIP && containerEl) {
|
|
||||||
scrollAnchor = { scrollTop: containerEl.scrollTop, scrollHeight: containerEl.scrollHeight };
|
|
||||||
stripChapters = [...stripChapters.slice(1), { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
|
||||||
tick().then(() => {
|
|
||||||
if (!scrollAnchor || !containerEl) return;
|
|
||||||
const gained = containerEl.scrollHeight - scrollAnchor.scrollHeight;
|
|
||||||
if (gained < 0) containerEl.scrollTop = Math.max(0, scrollAnchor.scrollTop + gained);
|
|
||||||
scrollAnchor = null;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
|
||||||
}
|
|
||||||
appending = false;
|
|
||||||
})
|
|
||||||
.catch(() => { appending = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupScrollTracking() {
|
|
||||||
if (!containerEl || style !== "longstrip") return;
|
|
||||||
const READ_LINE_PCT = 0.20;
|
|
||||||
function onScroll() {
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
|
||||||
let activeLocalPage: number | null = null;
|
|
||||||
let activeChId: number | null = null;
|
|
||||||
for (const img of imgs) {
|
|
||||||
const rect = img.getBoundingClientRect();
|
|
||||||
if (rect.top <= readLineY) { activeLocalPage = Number(img.dataset.localPage); activeChId = Number(img.dataset.chapter); }
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
|
|
||||||
if (activeLocalPage !== null) store.pageNumber = activeLocalPage;
|
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
|
||||||
if (store.settings.autoMarkRead && activeLocalPage !== null && activeChId) {
|
|
||||||
const chunk = stripChapters.find(c => c.chapterId === activeChId);
|
|
||||||
const total = chunk ? chunk.urls.length : store.pageUrls.length;
|
|
||||||
if (total > 0 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
|
|
||||||
markedRead.add(activeChId);
|
|
||||||
const chIdSnap = activeChId;
|
|
||||||
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
|
||||||
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
|
|
||||||
const last = stripChapters[stripChapters.length - 1];
|
|
||||||
if (last && store.settings.autoMarkRead && !markedRead.has(last.chapterId)) {
|
|
||||||
markedRead.add(last.chapterId);
|
|
||||||
const lastIdSnap = last.chapterId;
|
|
||||||
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
|
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onScroll80() {
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
|
||||||
if (pct >= 0.8) appendNextChapter();
|
|
||||||
}
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
if (autoNext) containerEl.addEventListener("scroll", onScroll80, { passive: true });
|
|
||||||
onScroll();
|
|
||||||
return () => { containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll80); };
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceGroup(forward: boolean) {
|
|
||||||
if (!pageGroups.length) return;
|
|
||||||
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
|
||||||
if (forward) {
|
|
||||||
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
|
|
||||||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
} else {
|
|
||||||
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goForward() {
|
|
||||||
if (loading) return;
|
|
||||||
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
|
||||||
if (!store.pageUrls.length) return;
|
|
||||||
if (store.pageNumber < lastPage) { decodeImage(store.pageUrls[store.pageNumber]).then(() => store.pageNumber++); }
|
|
||||||
else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
if (loading) return;
|
|
||||||
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
|
||||||
if (!store.pageUrls.length) return;
|
|
||||||
if (store.pageNumber > 1) { decodeImage(store.pageUrls[store.pageNumber - 2]).then(() => store.pageNumber--); }
|
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
|
||||||
}
|
|
||||||
|
|
||||||
const goNext = $derived(rtl ? goBack : goForward);
|
|
||||||
const goPrev = $derived(rtl ? goForward : goBack);
|
|
||||||
|
|
||||||
function cycleStyle() {
|
|
||||||
const opts = ["single", "longstrip"] as const;
|
|
||||||
const cur = style === "double" ? "single" : style;
|
|
||||||
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFit() {
|
|
||||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
|
||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.activeChapter && lastPage && store.activeManga) {
|
|
||||||
const chapterId = store.activeChapter.id;
|
|
||||||
const chapterName = store.activeChapter.name;
|
|
||||||
const mangaId = store.activeManga.id;
|
|
||||||
const mangaTitle = store.activeManga.title;
|
|
||||||
const thumbUrl = store.activeManga.thumbnailUrl;
|
|
||||||
const pageNum = store.pageNumber;
|
|
||||||
const atLast = store.pageNumber === lastPage;
|
|
||||||
untrack(() => {
|
|
||||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumbUrl, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
|
||||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) {
|
|
||||||
if (!markedRead.has(chapterId)) {
|
|
||||||
markedRead.add(chapterId);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: chapterId, isRead: true })
|
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chapterId ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "double" && store.pageUrls.length) {
|
|
||||||
let cancelled = false;
|
|
||||||
const snap = store.pageUrls;
|
|
||||||
Promise.all(snap.map(measureAspect)).then(aspects => {
|
|
||||||
if (cancelled || snap !== store.pageUrls) return;
|
|
||||||
const offset = store.settings.offsetDoubleSpreads;
|
|
||||||
const groups: number[][] = [[1]];
|
|
||||||
if (offset) groups.push([2]);
|
|
||||||
let i = offset ? 3 : 2;
|
|
||||||
while (i <= snap.length) {
|
|
||||||
const a = aspects[i - 1], nextA = aspects[i] ?? 0;
|
|
||||||
if (a > 1.2 || i === snap.length || nextA > 1.2) { groups.push([i++]); }
|
|
||||||
else { groups.push(rtl ? [i + 1, i] : [i, i + 1]); i += 2; }
|
|
||||||
}
|
|
||||||
pageGroups = groups;
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
} else { pageGroups = []; }
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const ahead = store.settings.preloadPages ?? 3;
|
|
||||||
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
|
||||||
const behind = store.pageUrls[store.pageNumber - 2];
|
|
||||||
if (behind) preloadImage(behind);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.activeChapter && store.activeChapterList.length) {
|
|
||||||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toPin: number[] = [store.activeChapter.id];
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
const entry = store.activeChapterList[idx + i];
|
|
||||||
if (!entry) break;
|
|
||||||
toPin.push(entry.id);
|
|
||||||
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {});
|
|
||||||
}
|
|
||||||
if (idx > 0) { const prev = store.activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
|
|
||||||
cacheEvict(new Set(toPin));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
|
||||||
appended = new Set([store.activeChapter.id]);
|
|
||||||
appending = false;
|
|
||||||
if (autoNext) {
|
|
||||||
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls, startGlobalIdx: 0 }];
|
|
||||||
visibleChapterId = store.activeChapter.id;
|
|
||||||
} else {
|
|
||||||
stripChapters = [];
|
|
||||||
visibleChapterId = null;
|
|
||||||
}
|
|
||||||
if (containerEl) containerEl.scrollTop = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (store.activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
|
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
|
||||||
if (!e.ctrlKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
|
||||||
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
|
||||||
const mW = store.settings.maxPageWidth ?? 900;
|
|
||||||
const r = store.settings.readingDirection === "rtl";
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (zoomOpen) { zoomOpen = false; return; }
|
|
||||||
if (dlOpen) { dlOpen = false; return; }
|
|
||||||
closeReader(); return;
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, mW + 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, mW - 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
|
||||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
|
||||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
|
||||||
const prev = idx > 0 ? list[idx - 1] : null;
|
|
||||||
if (prev) openReader(prev, list);
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
|
||||||
if (style === "longstrip") return;
|
|
||||||
const x = e.clientX / window.innerWidth;
|
|
||||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
|
||||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDl(fn: () => Promise<unknown>) {
|
|
||||||
dlBusy = true;
|
|
||||||
try { await fn(); } catch (e: any) { console.error(e); }
|
|
||||||
dlBusy = false; dlOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrollCleanup: (() => void) | undefined;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
showUi();
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
containerEl?.focus({ preventScroll: true });
|
|
||||||
scrollCleanup = setupScrollTracking();
|
|
||||||
return () => {
|
|
||||||
abortCtrl?.abort();
|
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
|
||||||
window.removeEventListener("keydown", onKey);
|
|
||||||
window.removeEventListener("wheel", onWheel);
|
|
||||||
scrollCleanup?.();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!containerEl) return;
|
|
||||||
const _style = style;
|
|
||||||
const _len = store.pageUrls.length;
|
|
||||||
const _auto = autoNext;
|
|
||||||
untrack(() => {
|
|
||||||
scrollCleanup?.();
|
|
||||||
scrollCleanup = setupScrollTracking();
|
|
||||||
});
|
|
||||||
return () => { scrollCleanup?.(); };
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripToRender = $derived(style === "longstrip"
|
|
||||||
? (autoNext && stripChapters.length > 0
|
|
||||||
? stripChapters
|
|
||||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls, startGlobalIdx: 0 }])
|
|
||||||
: []);
|
|
||||||
|
|
||||||
const currentGroup = $derived(style === "double" && pageGroups.length
|
|
||||||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
|
||||||
: [store.pageNumber]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
|
||||||
|
|
||||||
<div class="topbar" class:hidden={!uiVisible}>
|
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
|
|
||||||
<CaretLeft size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<span class="ch-label">
|
|
||||||
<span class="ch-title">{store.activeManga?.title}</span>
|
|
||||||
<span class="ch-sep">/</span>
|
|
||||||
<span>{displayChapter?.name}</span>
|
|
||||||
</span>
|
|
||||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
|
|
||||||
<CaretRight size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<div class="top-sep"></div>
|
|
||||||
<button class="mode-btn" onclick={cycleFit}>
|
|
||||||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
|
||||||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
|
||||||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
|
||||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
|
||||||
<span class="mode-label">{fitLabel}</span>
|
|
||||||
</button>
|
|
||||||
<div class="zoom-wrap">
|
|
||||||
<button class="zoom-btn" onclick={() => zoomOpen = !zoomOpen}>{Math.round((maxW / 900) * 100)}%</button>
|
|
||||||
{#if zoomOpen}
|
|
||||||
<div class="zoom-popover">
|
|
||||||
<input type="range" class="zoom-slider" min={200} max={2400} step={50} value={maxW}
|
|
||||||
oninput={(e) => updateSettings({ maxPageWidth: Number(e.currentTarget.value) })} />
|
|
||||||
<button class="zoom-reset" onclick={() => updateSettings({ maxPageWidth: 900 })}>{Math.round((maxW / 900) * 100)}%</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
|
||||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
|
||||||
</button>
|
|
||||||
<button class="mode-btn" onclick={cycleStyle}>
|
|
||||||
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
|
|
||||||
<span class="mode-label">{styleLabel}</span>
|
|
||||||
</button>
|
|
||||||
{#if style !== "single"}
|
|
||||||
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
|
||||||
<span class="mode-label">Gap</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if style === "longstrip"}
|
|
||||||
<button class="mode-btn" class:active={autoNext} onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
|
||||||
<span class="mode-label">Auto</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if !autoNext}
|
|
||||||
<button class="mode-btn" class:active={markOnNext} onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
|
||||||
<span class="mode-label">Mk.Read</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button class="mode-btn" onclick={() => dlOpen = true}>
|
|
||||||
<Download size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={containerEl}
|
|
||||||
class="viewer"
|
|
||||||
class:strip={style === "longstrip"}
|
|
||||||
style="--max-page-width:{maxW}px"
|
|
||||||
role="presentation"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={handleTap}
|
|
||||||
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
|
||||||
{/if}
|
|
||||||
{#if error}
|
|
||||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
|
||||||
{#each stripToRender as chunk}
|
|
||||||
{#each chunk.urls as url, i}
|
|
||||||
<img src={url} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
|
|
||||||
{:else if pageReady}
|
|
||||||
{#if style === "double" && pageGroups.length}
|
|
||||||
<div class="double-wrap">
|
|
||||||
{#each currentGroup as pg}
|
|
||||||
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
|
||||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
|
||||||
<ArrowRight size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if dlOpen && store.activeChapter}
|
|
||||||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
|
||||||
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
|
||||||
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<p class="dl-title">Download</p>
|
|
||||||
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
|
||||||
This chapter
|
|
||||||
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
|
||||||
</button>
|
|
||||||
<div class="dl-row">
|
|
||||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map(c => c.id) }))}>
|
|
||||||
Next chapters
|
|
||||||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
|
||||||
<span class="dl-step-val">{nextN}</span>
|
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
|
|
||||||
All remaining
|
|
||||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
|
||||||
.topbar { display: flex; align-items: center; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
|
||||||
.topbar.hidden, .bottombar.hidden { opacity: 0; pointer-events: none; }
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
|
||||||
.ch-label { flex: 1; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.ch-sep { color: var(--text-faint); }
|
|
||||||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
|
||||||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.mode-label { text-transform: capitalize; }
|
|
||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
|
||||||
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
|
||||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
|
||||||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; overflow-anchor: auto; }
|
|
||||||
.viewer:focus { outline: none; }
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
|
||||||
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
|
|
||||||
.fit-width { max-width: var(--max-page-width); width: 100%; height: auto; }
|
|
||||||
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
|
||||||
.fit-screen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
|
||||||
.fit-original { max-width: none; width: auto; height: auto; }
|
|
||||||
.strip-gap { margin-bottom: 8px; }
|
|
||||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--max-page-width) * 2); width: 100%; }
|
|
||||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gap-left { margin-right: 2px; }
|
|
||||||
.gap-right { margin-left: 2px; }
|
|
||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.bottombar { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
|
||||||
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
|
||||||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
|
||||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
|
||||||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
|
||||||
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
|
||||||
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
|
||||||
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
|
|
||||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -1,946 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { tick } from "svelte";
|
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
|
||||||
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
|
|
||||||
import { cache } from "../../lib/cache";
|
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
|
||||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
|
||||||
import type { Keybinds } from "../../lib/keybinds";
|
|
||||||
|
|
||||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: any }[] = [
|
|
||||||
{ id: "general", label: "General", icon: Gear },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: PaintBrush },
|
|
||||||
{ id: "reader", label: "Reader", icon: Book },
|
|
||||||
{ id: "library", label: "Library", icon: Image },
|
|
||||||
{ id: "performance",label: "Performance", icon: Sliders },
|
|
||||||
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
|
||||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
|
||||||
{ id: "folders", label: "Folders", icon: FolderSimple },
|
|
||||||
{ id: "about", label: "About", icon: Info },
|
|
||||||
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
|
||||||
];
|
|
||||||
|
|
||||||
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
|
||||||
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
|
|
||||||
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
|
|
||||||
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
|
|
||||||
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
|
|
||||||
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
|
|
||||||
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
let tab: Tab = $state("general");
|
|
||||||
let contentBodyEl: HTMLDivElement;
|
|
||||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
|
|
||||||
|
|
||||||
function close() { setSettingsOpen(false); }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
|
|
||||||
$effect(() => {
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let listeningKey: keyof Keybinds | null = $state(null);
|
|
||||||
|
|
||||||
function startListen(key: keyof Keybinds) {
|
|
||||||
listeningKey = listeningKey === key ? null : key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyCapture(e: KeyboardEvent) {
|
|
||||||
if (!listeningKey) return;
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
const bind = eventToKeybind(e);
|
|
||||||
if (!bind) return;
|
|
||||||
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
|
|
||||||
listeningKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (listeningKey) {
|
|
||||||
window.addEventListener("keydown", onKeyCapture, true);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyCapture, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
|
||||||
let storageInfo: StorageInfo | null = $state(null);
|
|
||||||
let storageLoading = $state(false);
|
|
||||||
let storageError: string | null = $state(null);
|
|
||||||
let clearing = $state(false);
|
|
||||||
let cleared = $state(false);
|
|
||||||
|
|
||||||
async function fetchStorage() {
|
|
||||||
storageLoading = true; storageError = null;
|
|
||||||
try {
|
|
||||||
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
|
|
||||||
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath });
|
|
||||||
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
|
|
||||||
finally { storageLoading = false; }
|
|
||||||
}
|
|
||||||
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
|
|
||||||
|
|
||||||
function handleClearCache() {
|
|
||||||
clearing = true;
|
|
||||||
caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))).catch(() => {})
|
|
||||||
.finally(() => { clearing = false; cleared = true; setTimeout(() => cleared = false, 2500); fetchStorage(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const units = ["B","KB","MB","GB","TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Performance metrics ───────────────────────────────────────────────────────
|
|
||||||
// Pulled from the session cache on demand — lightweight, no extra fetches.
|
|
||||||
interface PerfSnapshot {
|
|
||||||
cacheEntries: number;
|
|
||||||
cacheKeys: string[];
|
|
||||||
oldestEntryMs: number | null;
|
|
||||||
newestEntryMs: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let perfSnapshot: PerfSnapshot | null = $state(null);
|
|
||||||
|
|
||||||
function refreshPerfMetrics() {
|
|
||||||
// cache.list() isn't exported, but we can probe known keys to build a snapshot
|
|
||||||
const knownPrefixes = ["library", "sources", "popular", "genre:", "manga:", "chapters:", "page:", "pages:"];
|
|
||||||
let entries = 0;
|
|
||||||
let oldest: number | null = null;
|
|
||||||
let newest: number | null = null;
|
|
||||||
const foundKeys: string[] = [];
|
|
||||||
|
|
||||||
// We walk the cache via ageOf — non-zero means the key exists
|
|
||||||
// For a real count we introspect via a set of likely keys
|
|
||||||
// (The cache module doesn't expose an iterator, so we sample)
|
|
||||||
const checkKey = (k: string) => {
|
|
||||||
const age = cache.ageOf(k);
|
|
||||||
if (age !== undefined) {
|
|
||||||
entries++;
|
|
||||||
foundKeys.push(k);
|
|
||||||
const ts = Date.now() - age;
|
|
||||||
if (oldest === null || ts < oldest) oldest = ts;
|
|
||||||
if (newest === null || ts > newest) newest = ts;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
["library", "sources", "popular"].forEach(checkKey);
|
|
||||||
["Action","Romance","Fantasy","Comedy","Drama","Horror","Sci-Fi","Adventure","Thriller",
|
|
||||||
"Isekai","Supernatural","Historical","Psychological","Sports","Mystery","Mecha",
|
|
||||||
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
|
|
||||||
|
|
||||||
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
|
|
||||||
}
|
|
||||||
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
|
|
||||||
|
|
||||||
function fmtAge(ts: number | null): string {
|
|
||||||
if (ts === null) return "—";
|
|
||||||
const secs = Math.floor((Date.now() - ts) / 1000);
|
|
||||||
if (secs < 60) return `${secs}s ago`;
|
|
||||||
const mins = Math.floor(secs / 60);
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
return `${Math.floor(mins / 60)}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage limit input state
|
|
||||||
let storageLimitInput = $state(String(store.settings.storageLimitGb ?? ""));
|
|
||||||
|
|
||||||
function applyStorageLimit() {
|
|
||||||
const v = storageLimitInput.trim();
|
|
||||||
if (v === "" || v === "0") { updateSettings({ storageLimitGb: null }); return; }
|
|
||||||
const n = parseFloat(v);
|
|
||||||
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let newFolderName = $state("");
|
|
||||||
let editingId: string | null = $state(null);
|
|
||||||
let editingName = $state("");
|
|
||||||
|
|
||||||
function createFolder() {
|
|
||||||
const name = newFolderName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
addFolder(name); newFolderName = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(id: string, name: string) { editingId = id; editingName = name; }
|
|
||||||
|
|
||||||
function commitEdit() {
|
|
||||||
if (editingId && editingName.trim()) renameFolder(editingId, editingName.trim());
|
|
||||||
editingId = null; editingName = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let selectOpen: string | null = $state(null);
|
|
||||||
|
|
||||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
|
||||||
|
|
||||||
function onSelectOutside(e: MouseEvent) {
|
|
||||||
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.addEventListener("mousedown", onSelectOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", onSelectOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let splashTriggered = $state(false);
|
|
||||||
|
|
||||||
let appVersion = $state("…");
|
|
||||||
let latestVersion = $state<string | null>(null);
|
|
||||||
let checkingUpdate = $state(false);
|
|
||||||
let updateError = $state<string | null>(null);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (tab === "about") {
|
|
||||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkForUpdate() {
|
|
||||||
checkingUpdate = true; updateError = null; latestVersion = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch("https://api.github.com/repos/Youwes09/Moku/releases/latest", {
|
|
||||||
method: "GET",
|
|
||||||
headers: { "User-Agent": "Moku" },
|
|
||||||
});
|
|
||||||
const data = await res.json() as { tag_name: string };
|
|
||||||
latestVersion = data.tag_name.replace(/^v/, "");
|
|
||||||
} catch (e) {
|
|
||||||
updateError = "Could not reach GitHub";
|
|
||||||
} finally {
|
|
||||||
checkingUpdate = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function triggerSplash() {
|
|
||||||
splashTriggered = true;
|
|
||||||
setTimeout(() => splashTriggered = false, 200);
|
|
||||||
(window as any).__mokuShowSplash?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
|
||||||
<div class="modal" role="dialog" aria-label="Settings">
|
|
||||||
<div class="sidebar">
|
|
||||||
<p class="modal-title">Settings</p>
|
|
||||||
<nav class="nav">
|
|
||||||
{#each TABS as t}
|
|
||||||
<button class="nav-item" class:active={tab === t.id} onclick={() => tab = t.id}>
|
|
||||||
<t.icon size={14} weight="light" />
|
|
||||||
<span>{t.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="content-header">
|
|
||||||
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
|
||||||
<button class="close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-body" bind:this={contentBodyEl}>
|
|
||||||
|
|
||||||
|
|
||||||
{#if tab === "general"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Interface Scale</p>
|
|
||||||
<div class="scale-row">
|
|
||||||
<input type="range" min={70} max={200} step={5} value={store.settings.uiScale}
|
|
||||||
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
|
|
||||||
<span class="scale-val">{store.settings.uiScale}%</span>
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset">↺</button>
|
|
||||||
</div>
|
|
||||||
<p class="scale-hint">
|
|
||||||
{#each [70,80,90,100,110,125,150,175,200] as v}
|
|
||||||
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Server</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
|
|
||||||
<input class="text-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Inactivity</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
|
|
||||||
<div class="select-wrap" id="idle-timeout">
|
|
||||||
<button class="select-btn" onclick={() => toggleSelect("idle-timeout")}>
|
|
||||||
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
|
|
||||||
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if selectOpen === "idle-timeout"}
|
|
||||||
<div class="select-menu">
|
|
||||||
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
|
|
||||||
<button class="select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "appearance"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Theme</p>
|
|
||||||
<div class="theme-grid">
|
|
||||||
{#each THEMES as theme}
|
|
||||||
{@const active = (store.settings.theme ?? "dark") === theme.id}
|
|
||||||
<button class="theme-card" class:active onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
|
||||||
<div class="theme-preview">
|
|
||||||
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
|
|
||||||
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
|
||||||
<div class="theme-preview-content">
|
|
||||||
<div class="theme-preview-accent" style="background:{theme.swatches[2]}"></div>
|
|
||||||
<div class="theme-preview-text" style="background:{theme.swatches[3]}55"></div>
|
|
||||||
<div class="theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="theme-card-info">
|
|
||||||
<span class="theme-card-label">{theme.label}</span>
|
|
||||||
<span class="theme-card-desc">{theme.description}</span>
|
|
||||||
</div>
|
|
||||||
{#if active}<span class="theme-card-check">✓</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "reader"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Page Layout</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
|
|
||||||
<div class="select-wrap" id="page-style">
|
|
||||||
<button class="select-btn" onclick={() => toggleSelect("page-style")}>
|
|
||||||
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
|
|
||||||
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if selectOpen === "page-style"}
|
|
||||||
<div class="select-menu">
|
|
||||||
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
|
||||||
<button class="select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
|
||||||
<div class="select-wrap" id="reading-dir">
|
|
||||||
<button class="select-btn" onclick={() => toggleSelect("reading-dir")}>
|
|
||||||
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
|
|
||||||
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if selectOpen === "reading-dir"}
|
|
||||||
<div class="select-menu">
|
|
||||||
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
|
||||||
<button class="select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Fit & Zoom</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
|
|
||||||
<div class="select-wrap" id="fit-mode">
|
|
||||||
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
|
|
||||||
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
|
|
||||||
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if selectOpen === "fit-mode"}
|
|
||||||
<div class="select-menu">
|
|
||||||
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
|
||||||
<button class="select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
|
|
||||||
<div class="step-controls">
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
|
||||||
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span>
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Behaviour</p>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
{#if !(store.settings.autoNextChapter ?? false)}
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
|
|
||||||
<div class="step-controls">
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}>−</button>
|
|
||||||
<span class="step-val">{store.settings.preloadPages}</span>
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "library"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Display</p>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Chapters</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
|
|
||||||
<div class="select-wrap" id="sort-dir">
|
|
||||||
<button class="select-btn" onclick={() => toggleSelect("sort-dir")}>
|
|
||||||
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
|
|
||||||
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
{#if selectOpen === "sort-dir"}
|
|
||||||
<div class="select-menu">
|
|
||||||
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
|
||||||
<button class="select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">History</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Reading store.history</span><span class="toggle-desc">{store.history.length} entries stored</span></div>
|
|
||||||
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button>
|
|
||||||
</div>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Full data cleanse</span>
|
|
||||||
<span class="toggle-desc">Removes store.history, stats, completed list, hero pins, and manga links</span>
|
|
||||||
</div>
|
|
||||||
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "performance"}
|
|
||||||
<div class="panel">
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Render Limit</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Items per page</span>
|
|
||||||
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
|
|
||||||
</div>
|
|
||||||
<div class="step-controls">
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}>−</button>
|
|
||||||
<span class="step-val">{store.settings.renderLimit ?? 48}</span>
|
|
||||||
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="scale-hint">
|
|
||||||
{#each [12, 24, 48, 96, 200] as v}
|
|
||||||
<button class="scale-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Rendering</p>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Idle / Splash Screen</p>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Interface</p>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
|
||||||
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Session Cache</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Cache entries</span>
|
|
||||||
<span class="toggle-desc">In-memory request cache for this session (library, sources, genre pages). Cleared on restart.</span>
|
|
||||||
</div>
|
|
||||||
<div class="perf-stat-group">
|
|
||||||
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
|
|
||||||
<button class="kb-reset" onclick={refreshPerfMetrics} title="Refresh">↺</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Oldest entry</span></div>
|
|
||||||
<span class="perf-stat">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Newest entry</span></div>
|
|
||||||
<span class="perf-stat">{fmtAge(perfSnapshot.newestEntryMs)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Cached keys</span>
|
|
||||||
<span class="toggle-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "keybinds"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<div class="kb-header">
|
|
||||||
<p class="section-title">Keyboard shortcuts</p>
|
|
||||||
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
|
|
||||||
</div>
|
|
||||||
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
|
|
||||||
<div class="kb-list">
|
|
||||||
{#each Object.keys(KEYBIND_LABELS) as key}
|
|
||||||
{@const k = key as keyof Keybinds}
|
|
||||||
{@const isListening = listeningKey === k}
|
|
||||||
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
|
|
||||||
<div class="kb-row">
|
|
||||||
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
|
|
||||||
<div class="kb-right">
|
|
||||||
<button class="kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
|
|
||||||
{isListening ? "Press key…" : store.settings.keybinds[k]}
|
|
||||||
</button>
|
|
||||||
<button class="kb-reset" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset">↺</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "storage"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Disk Usage</p>
|
|
||||||
{#if storageLoading}<p class="storage-loading">Reading filesystem…</p>
|
|
||||||
{:else if storageError}<p class="storage-loading" style="color:var(--color-error)">{storageError}</p>
|
|
||||||
{:else if storageInfo}
|
|
||||||
{@const mangaBytes = storageInfo.manga_bytes}
|
|
||||||
{@const totalBytes = storageInfo.total_bytes}
|
|
||||||
{@const freeBytes = storageInfo.free_bytes}
|
|
||||||
{@const limitGb = store.settings.storageLimitGb ?? null}
|
|
||||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
|
||||||
{@const available = mangaBytes + freeBytes}
|
|
||||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
|
||||||
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0}
|
|
||||||
<div class="storage-bar-wrap">
|
|
||||||
<div class="storage-bar">
|
|
||||||
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="storage-bar-labels">
|
|
||||||
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span>
|
|
||||||
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="storage-legend">
|
|
||||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-manga"></span><span class="storage-legend-label">Downloaded manga</span><span class="storage-legend-val">{fmtBytes(mangaBytes)}</span></div>
|
|
||||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-free"></span><span class="storage-legend-label">Drive free</span><span class="storage-legend-val">{fmtBytes(freeBytes)}</span></div>
|
|
||||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-app"></span><span class="storage-legend-label">Drive total</span><span class="storage-legend-val">{fmtBytes(totalBytes)}</span></div>
|
|
||||||
</div>
|
|
||||||
<p class="storage-path-note">{storageInfo.path}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Cache</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
|
|
||||||
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
|
|
||||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Storage Limit</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Limit download storage</span>
|
|
||||||
<span class="toggle-desc">
|
|
||||||
{store.settings.storageLimitGb === null
|
|
||||||
? "No limit — uses full drive capacity"
|
|
||||||
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if store.settings.storageLimitGb === null}
|
|
||||||
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
|
||||||
onclick={() => updateSettings({ storageLimitGb: 10 })}>
|
|
||||||
Set limit
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="step-controls">
|
|
||||||
<button class="step-btn"
|
|
||||||
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })}
|
|
||||||
disabled={(store.settings.storageLimitGb ?? 10) <= 1}>−</button>
|
|
||||||
<input
|
|
||||||
type="number" min="1" step="1"
|
|
||||||
class="storage-limit-input"
|
|
||||||
value={store.settings.storageLimitGb}
|
|
||||||
oninput={(e) => {
|
|
||||||
const n = parseFloat(e.currentTarget.value);
|
|
||||||
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span class="storage-limit-unit">GB</span>
|
|
||||||
<button class="step-btn"
|
|
||||||
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
|
||||||
<button class="kb-reset" title="Remove limit"
|
|
||||||
onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "folders"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Manage Folders</p>
|
|
||||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p>
|
|
||||||
<div class="folder-create-row">
|
|
||||||
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
|
|
||||||
<button class="folder-create-btn" onclick={createFolder} disabled={!newFolderName.trim()}>
|
|
||||||
<Plus size={13} weight="bold" /> Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if store.settings.folders.length === 0}
|
|
||||||
<p class="storage-loading">No folders yet. Create one above.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="folder-list">
|
|
||||||
{#each store.settings.folders as folder}
|
|
||||||
<div class="folder-row">
|
|
||||||
{#if editingId === folder.id}
|
|
||||||
<input class="text-input" bind:value={editingName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
|
||||||
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
|
|
||||||
<button class="kb-reset" onclick={commitEdit} title="Save">✓</button>
|
|
||||||
{:else}
|
|
||||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
|
||||||
<span class="folder-row-name">{folder.name}</span>
|
|
||||||
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
|
|
||||||
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}>
|
|
||||||
{folder.showTab ? "Tab on" : "Tab off"}
|
|
||||||
</button>
|
|
||||||
<button class="kb-reset" onclick={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
|
||||||
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "about"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Moku</p>
|
|
||||||
<div class="about-block">
|
|
||||||
<p class="about-line">A manga reader frontend for Suwayomi / Tachidesk.</p>
|
|
||||||
<p class="about-line" style="color:var(--text-faint);margin-top:var(--sp-2)">Built with Tauri + Svelte.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Version</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info">
|
|
||||||
<span class="toggle-label">Current version</span>
|
|
||||||
<span class="toggle-desc">v{appVersion}</span>
|
|
||||||
</div>
|
|
||||||
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
|
||||||
onclick={checkForUpdate} disabled={checkingUpdate}>
|
|
||||||
{checkingUpdate ? "Checking…" : "Check for updates"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if updateError}
|
|
||||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--color-error);padding:0 var(--sp-3) var(--sp-2)">{updateError}</p>
|
|
||||||
{:else if latestVersion !== null}
|
|
||||||
{#if latestVersion === appVersion}
|
|
||||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#22c55e;padding:0 var(--sp-3) var(--sp-2);letter-spacing:var(--tracking-wide)">✓ You are on the latest version</p>
|
|
||||||
{:else}
|
|
||||||
<div style="padding:0 var(--sp-3) var(--sp-2);display:flex;flex-direction:column;gap:var(--sp-1)">
|
|
||||||
<p style="font-family:var(--font-ui);font-size:var(--text-xs);color:#fb923c;letter-spacing:var(--tracking-wide)">Update available — v{latestVersion}</p>
|
|
||||||
<a href="https://github.com/Youwes09/Moku/releases/latest" target="_blank"
|
|
||||||
style="font-family:var(--font-ui);font-size:var(--text-xs);color:var(--accent-fg);letter-spacing:var(--tracking-wide);text-decoration:none">
|
|
||||||
Download on GitHub →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Links</p>
|
|
||||||
<div class="about-block">
|
|
||||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{:else if tab === "devtools"}
|
|
||||||
<div class="panel">
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Splash Screen</p>
|
|
||||||
<div class="step-row">
|
|
||||||
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
|
|
||||||
<button class="danger-btn" onclick={triggerSplash}
|
|
||||||
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
|
|
||||||
Show idle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<p class="section-title">Build Info</p>
|
|
||||||
<div class="about-block">
|
|
||||||
<p class="about-line" style="font-family:monospace;font-size:11px;color:var(--text-faint)">Mode: {import.meta.env.MODE}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusInput(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); }
|
|
||||||
.modal { width: min(720px, calc(100vw - 48px)); height: min(600px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.15s ease both; box-shadow: 0 24px 64px rgba(0,0,0,0.6); }
|
|
||||||
.sidebar { width: 168px; flex-shrink: 0; background: var(--bg-base); border-right: 1px solid var(--border-dim); padding: var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); overflow-y: auto; }
|
|
||||||
.modal-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 0 var(--sp-2) var(--sp-3); }
|
|
||||||
.nav { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.nav-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.nav-item:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.nav-item.active { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
||||||
.content-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.content-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.content-body { flex: 1; overflow-y: auto; }
|
|
||||||
|
|
||||||
.panel { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4) var(--sp-6); }
|
|
||||||
.section { display: flex; flex-direction: column; gap: 1px; border-bottom: 1px solid var(--border-dim); padding-bottom: var(--sp-4); margin-bottom: var(--sp-2); }
|
|
||||||
.section:last-child { border-bottom: none; }
|
|
||||||
.section-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
|
|
||||||
|
|
||||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); cursor: default; transition: background var(--t-fast); }
|
|
||||||
.toggle-row:hover { background: var(--bg-raised); }
|
|
||||||
.toggle-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; margin-right: var(--sp-4); }
|
|
||||||
.toggle-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
|
||||||
.toggle-desc { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
|
||||||
.toggle { position: relative; width: 32px; height: 18px; border-radius: var(--radius-full); border: none; background: var(--bg-overlay); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); border: 1px solid var(--border-strong); }
|
|
||||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
|
||||||
.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
|
||||||
.toggle.on .toggle-thumb { transform: translateX(14px); background: var(--bg-void); }
|
|
||||||
|
|
||||||
.step-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); gap: var(--sp-3); }
|
|
||||||
.step-row:hover { background: var(--bg-raised); }
|
|
||||||
.step-controls { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.step-btn { font-family: var(--font-ui); font-size: var(--text-sm); width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; }
|
|
||||||
.step-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
.step-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); min-width: 40px; text-align: center; }
|
|
||||||
|
|
||||||
.select-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.select-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; cursor: pointer; min-width: 130px; transition: border-color var(--t-base); }
|
|
||||||
.select-btn:hover { border-color: var(--border-strong); }
|
|
||||||
.select-caret { color: var(--text-faint); transition: transform var(--t-base); flex-shrink: 0; margin-left: auto; }
|
|
||||||
.select-caret.open { transform: rotate(180deg); }
|
|
||||||
.select-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 100%; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.4); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
|
||||||
.select-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.select-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.select-option.active { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.text-input { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; width: 200px; transition: border-color var(--t-base); flex-shrink: 0; }
|
|
||||||
.text-input:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.danger-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--color-error); background: none; color: var(--color-error); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); }
|
|
||||||
.danger-btn:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.danger-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
|
||||||
.scale-slider { flex: 1; }
|
|
||||||
.scale-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 40px; text-align: center; }
|
|
||||||
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
|
|
||||||
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.scale-preset.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* Theme */
|
|
||||||
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
|
||||||
.theme-card { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; text-align: left; transition: border-color var(--t-base), box-shadow var(--t-base); position: relative; }
|
|
||||||
.theme-card:hover { border-color: var(--border-strong); }
|
|
||||||
.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
||||||
.theme-preview { height: 70px; overflow: hidden; }
|
|
||||||
.theme-preview-bg { width: 100%; height: 100%; display: flex; }
|
|
||||||
.theme-preview-sidebar { width: 20%; height: 100%; flex-shrink: 0; }
|
|
||||||
.theme-preview-content { flex: 1; padding: 8px 6px; display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
.theme-preview-accent { height: 6px; width: 50%; border-radius: 3px; }
|
|
||||||
.theme-preview-text { height: 4px; width: 100%; border-radius: 2px; }
|
|
||||||
.theme-card-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.theme-card-label { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.theme-card-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
|
|
||||||
|
|
||||||
/* Keybinds */
|
|
||||||
.kb-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-3) var(--sp-2); }
|
|
||||||
.reset-all-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.reset-all-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
|
|
||||||
.kb-list { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.kb-row:hover { background: var(--bg-raised); }
|
|
||||||
.kb-label { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
|
|
||||||
.kb-right { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.kb-bind { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-secondary); cursor: pointer; min-width: 90px; text-align: center; transition: border-color var(--t-base), color var(--t-base); }
|
|
||||||
.kb-bind:hover { border-color: var(--border-strong); }
|
|
||||||
.kb-bind.listening { border-color: var(--accent); color: var(--accent-fg); background: var(--accent-muted); animation: pulse 1s ease infinite; }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.6 } }
|
|
||||||
.kb-reset { font-size: var(--text-sm); color: var(--text-faint); padding: 3px 6px; border-radius: var(--radius-sm); border: 1px solid transparent; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.kb-reset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); background: var(--bg-overlay); }
|
|
||||||
.kb-reset:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* Storage */
|
|
||||||
.storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); }
|
|
||||||
.storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
|
||||||
.storage-bar-fill.warn { background: #d97706; }
|
|
||||||
.storage-bar-fill.critical { background: var(--color-error); }
|
|
||||||
.storage-bar-labels { display: flex; justify-content: space-between; }
|
|
||||||
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
|
|
||||||
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
||||||
.storage-dot-manga { background: var(--accent); }
|
|
||||||
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
|
||||||
.storage-dot-app { background: var(--text-faint); }
|
|
||||||
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
|
|
||||||
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
|
|
||||||
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
|
|
||||||
|
|
||||||
/* Folders */
|
|
||||||
.folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
|
|
||||||
.folder-create-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
|
||||||
.folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.folder-create-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.folder-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-3); }
|
|
||||||
.folder-row { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
|
||||||
.folder-row:hover { background: var(--bg-raised); }
|
|
||||||
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
|
|
||||||
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
|
||||||
|
|
||||||
/* About */
|
|
||||||
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.about-line { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); }
|
|
||||||
|
|
||||||
/* Perf metrics */
|
|
||||||
.perf-stat-group { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
||||||
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* Storage limit */
|
|
||||||
.storage-limit-input {
|
|
||||||
width: 64px; text-align: center;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm); padding: 3px 6px;
|
|
||||||
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
outline: none; transition: border-color var(--t-base);
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
.storage-limit-input::-webkit-inner-spin-button,
|
|
||||||
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
|
||||||
.storage-limit-input:focus { border-color: var(--border-strong); }
|
|
||||||
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
</style>
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export interface MenuItem {
|
|
||||||
label: string;
|
|
||||||
icon?: any;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
separator?: never;
|
|
||||||
}
|
|
||||||
export interface MenuSeparator { separator: true }
|
|
||||||
export type MenuEntry = MenuItem | MenuSeparator;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: MenuEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { x, y, items, onClose }: Props = $props();
|
|
||||||
|
|
||||||
let focused = $state(-1);
|
|
||||||
let el = $state<HTMLDivElement | undefined>(undefined);
|
|
||||||
|
|
||||||
const actionable = $derived(
|
|
||||||
items
|
|
||||||
.map((_, i) => i)
|
|
||||||
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled)
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
|
|
||||||
|
|
||||||
const pos = $derived.by(() => {
|
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
|
||||||
const menuW = 200, menuH = items.length * 34;
|
|
||||||
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
|
|
||||||
const sx = x / zoom, sy = y / zoom;
|
|
||||||
return {
|
|
||||||
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
|
|
||||||
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
|
||||||
if (el && !el.contains(e.target as Node)) onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = actionable.indexOf(focused);
|
|
||||||
focused = actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = actionable.indexOf(focused);
|
|
||||||
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && focused >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
const item = items[focused] as MenuItem;
|
|
||||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.addEventListener("mousedown", onMouseDown, true);
|
|
||||||
document.addEventListener("keydown", onKey, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onMouseDown, true);
|
|
||||||
document.removeEventListener("keydown", onKey, true);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={el} class="menu" role="menu" tabindex="-1" style="left:{pos.left}px;top:{pos.top}px"
|
|
||||||
oncontextmenu={(e) => e.preventDefault()}>
|
|
||||||
{#each items as item, i}
|
|
||||||
{#if "separator" in item}
|
|
||||||
<div class="sep"></div>
|
|
||||||
{:else}
|
|
||||||
{@const mi = item as MenuItem}
|
|
||||||
<button
|
|
||||||
class="item"
|
|
||||||
class:danger={mi.danger}
|
|
||||||
class:disabled={mi.disabled}
|
|
||||||
class:focused={focused === i}
|
|
||||||
disabled={mi.disabled}
|
|
||||||
onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
|
||||||
onmouseenter={() => { if (!mi.disabled) focused = i; }}
|
|
||||||
onmouseleave={() => focused = -1}
|
|
||||||
>
|
|
||||||
<span class="icon" class:icon-danger={mi.danger}>
|
|
||||||
{#if mi.icon}<mi.icon size={13} weight="light" />{/if}
|
|
||||||
</span>
|
|
||||||
<span class="label">{mi.label}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
position: fixed; z-index: 200;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--sp-1); min-width: 190px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.35), 0 16px 40px rgba(0,0,0,0.25);
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
.item {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
width: 100%; padding: 5px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
background: none; border: none; outline: none;
|
|
||||||
}
|
|
||||||
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.item.danger { color: var(--color-error); }
|
|
||||||
.item.danger:hover:not(.disabled), .item.danger.focused:not(.disabled) { background: var(--color-error-bg); }
|
|
||||||
.item.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
|
|
||||||
.icon {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; flex-shrink: 0;
|
|
||||||
color: var(--text-faint); border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.icon-danger { color: var(--color-error); opacity: 0.7; }
|
|
||||||
.label { flex: 1; line-height: 1.3; }
|
|
||||||
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
|
|
||||||
</style>
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingDetail = $state(false);
|
|
||||||
let loadingChapters = $state(false);
|
|
||||||
let togglingLib = $state(false);
|
|
||||||
let descExpanded = $state(false);
|
|
||||||
let folderOpen = $state(false);
|
|
||||||
let newFolderName = $state("");
|
|
||||||
let creatingFolder = $state(false);
|
|
||||||
let queueingAll = $state(false);
|
|
||||||
let fetchError: string|null = $state(null);
|
|
||||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
|
||||||
|
|
||||||
let linkPickerOpen = $state(false);
|
|
||||||
let linkSearch = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
|
||||||
let loadingLinkList = $state(false);
|
|
||||||
|
|
||||||
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
|
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
|
||||||
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
|
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLinkPicker() {
|
|
||||||
linkPickerOpen = true; linkSearch = "";
|
|
||||||
if (allMangaForLink.length) return;
|
|
||||||
loadingLinkList = true;
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
|
||||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => { loadingLinkList = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
|
||||||
if (!store.previewManga) return;
|
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
|
|
||||||
else linkManga(store.previewManga.id, other.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
setPreviewManga(null);
|
|
||||||
manga = null; chapters = []; descExpanded = false;
|
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? store.previewManga);
|
|
||||||
const totalCount = $derived(chapters.length);
|
|
||||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
|
||||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
|
||||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
|
|
||||||
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
|
|
||||||
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
|
|
||||||
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
|
|
||||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
|
||||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
|
||||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
|
||||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
|
||||||
|
|
||||||
const continueChapter = $derived.by(() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
|
||||||
const firstUnread = chapters.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
|
||||||
return { ch: chapters[0], label: "Read again" };
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
|
||||||
|
|
||||||
async function load(id: number) {
|
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
|
||||||
loadingDetail = true; loadingChapters = true;
|
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
|
||||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
|
||||||
return d.fetchManga.manga;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") throw e;
|
|
||||||
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
|
|
||||||
if (local) return local;
|
|
||||||
throw new Error("Could not load manga details");
|
|
||||||
}
|
|
||||||
})().then((fullManga) => {
|
|
||||||
if (dCtrl.signal.aborted) return;
|
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
|
||||||
manga = fullManga; loadingDetail = false;
|
|
||||||
}).catch((e) => {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
manga = store.previewManga as Manga;
|
|
||||||
fetchError = "Could not load full details — showing cached data";
|
|
||||||
loadingDetail = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
|
|
||||||
.then(async (d) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
|
|
||||||
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) { if (e?.name === "AbortError") return; }
|
|
||||||
}
|
|
||||||
if (!cCtrl.signal.aborted) {
|
|
||||||
chapters = nodes;
|
|
||||||
if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
togglingLib = true;
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
manga = { ...manga, inLibrary: next };
|
|
||||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
|
||||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
togglingLib = false;
|
|
||||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAll() {
|
|
||||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
queueingAll = true;
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
|
||||||
queueingAll = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSeriesDetail() {
|
|
||||||
if (!displayManga) return;
|
|
||||||
setActiveManga(displayManga);
|
|
||||||
setNavPage("library");
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderCreate() {
|
|
||||||
const name = newFolderName.trim();
|
|
||||||
if (!name || !store.previewManga) return;
|
|
||||||
const id = addFolder(name);
|
|
||||||
assignMangaToFolder(id, store.previewManga.id);
|
|
||||||
newFolderName = ""; creatingFolder = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderOutside(e: MouseEvent) {
|
|
||||||
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (folderOpen) {
|
|
||||||
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
|
||||||
return () => document.removeEventListener("mousedown", handleFolderOutside);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
|
||||||
onMount(() => window.addEventListener("keydown", onKey));
|
|
||||||
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.previewManga}
|
|
||||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
|
||||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
|
||||||
|
|
||||||
<div class="cover-col">
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="cover-actions">
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
|
||||||
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="action-btn" onclick={openSeriesDetail}>
|
|
||||||
<span class="action-icon"><Books size={13} weight="light" /></span>
|
|
||||||
<span class="action-label">Series Detail</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="folder-wrap" bind:this={folderRef}>
|
|
||||||
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
|
|
||||||
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
|
||||||
</button>
|
|
||||||
{#if folderOpen}
|
|
||||||
<div class="folder-menu">
|
|
||||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
|
||||||
{#each store.settings.folders as f}
|
|
||||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
|
||||||
<button class="folder-item" class:folder-item-on={isIn}
|
|
||||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
|
||||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="folder-divider"></div>
|
|
||||||
{#if creatingFolder}
|
|
||||||
<div class="folder-create-row">
|
|
||||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
|
|
||||||
use:focusAction />
|
|
||||||
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
|
|
||||||
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
|
|
||||||
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="content-header">
|
|
||||||
<div class="title-block">
|
|
||||||
<h2 class="title">{displayManga?.title}</h2>
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-byline"></div>
|
|
||||||
{:else if displayManga?.author || displayManga?.artist}
|
|
||||||
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-body">
|
|
||||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
|
||||||
{:else}
|
|
||||||
<div class="badges">
|
|
||||||
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
|
|
||||||
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
|
|
||||||
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
|
|
||||||
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
|
|
||||||
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="chapter-box">
|
|
||||||
{#if loadingChapters}
|
|
||||||
<div class="chapter-loading">
|
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
|
||||||
<span class="chapter-loading-label">Loading chapters…</span>
|
|
||||||
</div>
|
|
||||||
{:else if totalCount > 0}
|
|
||||||
<div class="chapter-meta">
|
|
||||||
<span class="chapter-label">
|
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
|
|
||||||
</span>
|
|
||||||
{#if unreadCount > 0}
|
|
||||||
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
|
|
||||||
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
|
|
||||||
{queueingAll ? "Queuing…" : "Download unread"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if readCount > 0}
|
|
||||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
|
||||||
{/if}
|
|
||||||
{#if continueChapter}
|
|
||||||
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
|
||||||
<Play size={12} weight="fill" />{continueChapter.label}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else if !loadingDetail}
|
|
||||||
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loadingDetail}
|
|
||||||
<div class="sk-desc">
|
|
||||||
<div class="sk-line" style="width:100%"></div>
|
|
||||||
<div class="sk-line" style="width:88%"></div>
|
|
||||||
<div class="sk-line" style="width:70%"></div>
|
|
||||||
</div>
|
|
||||||
{:else if displayManga?.description}
|
|
||||||
<div class="desc-block">
|
|
||||||
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
|
|
||||||
{#if displayManga.description.length > 220}
|
|
||||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
|
|
||||||
{descExpanded ? "Show less" : "Show more"}
|
|
||||||
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail && displayManga?.genre?.length}
|
|
||||||
<div class="genres">
|
|
||||||
{#each displayManga.genre as g}
|
|
||||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loadingDetail}
|
|
||||||
<div class="meta-table">
|
|
||||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
|
||||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
|
||||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
|
||||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
|
||||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
|
||||||
{#if !loadingChapters && firstUpload && lastUpload}
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="meta-key">Published</span>
|
|
||||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
|
||||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
|
||||||
<div class="link-backdrop" role="presentation"
|
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
|
||||||
<div class="link-modal">
|
|
||||||
<div class="link-header">
|
|
||||||
<span class="link-title">Link as same series</span>
|
|
||||||
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<p class="link-hint">
|
|
||||||
Mark two manga as the same series so duplicates are merged in search and discover.
|
|
||||||
Click a linked entry again to unlink.
|
|
||||||
</p>
|
|
||||||
<div class="link-search-wrap">
|
|
||||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
|
|
||||||
</div>
|
|
||||||
<div class="link-list">
|
|
||||||
{#if loadingLinkList}
|
|
||||||
<p class="link-empty">Loading…</p>
|
|
||||||
{:else if linkPickerResults.length === 0}
|
|
||||||
<p class="link-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each linkPickerResults as m (m.id)}
|
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusAction(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
|
||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
|
||||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
|
||||||
.cover-wrap { position: relative; width: 100%; }
|
|
||||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
|
|
||||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
||||||
.folder-wrap { position: relative; width: 100%; }
|
|
||||||
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
|
||||||
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
|
|
||||||
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
|
||||||
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.folder-item.folder-item-on { color: var(--accent-fg); }
|
|
||||||
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
|
||||||
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
|
|
||||||
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
|
|
||||||
.folder-input:focus { border-color: var(--border-focus); }
|
|
||||||
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
|
|
||||||
.folder-ok:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
|
||||||
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
|
|
||||||
.folder-new:hover { color: var(--accent-fg); }
|
|
||||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
|
||||||
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
||||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
|
|
||||||
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
|
|
||||||
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
|
|
||||||
.content-body::-webkit-scrollbar { display: none; }
|
|
||||||
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
|
||||||
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
|
||||||
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
|
|
||||||
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
||||||
.badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
|
||||||
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
|
|
||||||
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
|
|
||||||
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
|
||||||
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
||||||
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
|
||||||
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
|
|
||||||
.read-btn:hover { filter: brightness(1.1); }
|
|
||||||
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
|
||||||
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
|
||||||
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
|
|
||||||
.desc-toggle:hover { color: var(--accent-fg); }
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
|
||||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
|
||||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
|
||||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
|
||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
|
||||||
.meta-link:hover { opacity: 0.75; }
|
|
||||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
|
|
||||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
|
||||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
|
||||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
|
||||||
.link-search:focus { border-color: var(--border-strong); }
|
|
||||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.link-list::-webkit-scrollbar { display: none; }
|
|
||||||
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
|
||||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./selectPortal";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@attach selectPortal(triggerEl)}
|
||||||
|
*
|
||||||
|
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
||||||
|
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
||||||
|
*
|
||||||
|
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
||||||
|
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
||||||
|
*/
|
||||||
|
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||||
|
return (menuEl: HTMLElement) => {
|
||||||
|
// Position & move to body
|
||||||
|
function position() {
|
||||||
|
const r = triggerEl.getBoundingClientRect();
|
||||||
|
menuEl.style.position = "fixed";
|
||||||
|
menuEl.style.top = `${r.bottom + 4}px`;
|
||||||
|
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||||
|
// clamp to viewport left edge
|
||||||
|
const left = parseFloat(menuEl.style.left);
|
||||||
|
if (left < 8) menuEl.style.left = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(menuEl);
|
||||||
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
|
position();
|
||||||
|
|
||||||
|
// Reposition on scroll / resize while open
|
||||||
|
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);
|
||||||
|
}
|
||||||