Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 | |||
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 |
@@ -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/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \
|
||||
-o suwayomi-linux.tar.gz
|
||||
|
||||
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe 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:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.3.0)"
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
@@ -40,9 +43,6 @@ jobs:
|
||||
name: Tauri (macOS)
|
||||
needs: frontend
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -100,149 +100,95 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
find_launcher() {
|
||||
local dir="$1"
|
||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||
}
|
||||
stage_arch() {
|
||||
local srcdir="$1"
|
||||
local arch="$2"
|
||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||
|
||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||
|
||||
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
||||
echo "ERROR: could not find launchers — tarball contents:"
|
||||
ls -lR suwayomi-arm64 suwayomi-x64
|
||||
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 "arm64 launcher: $ARM_LAUNCHER"
|
||||
echo "x64 launcher: $X64_LAUNCHER"
|
||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||
|
||||
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
|
||||
cp -r "$srcdir" "$bundle_dest"
|
||||
|
||||
# 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
|
||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||
chmod +x "$sidecar"
|
||||
echo "Staged sidecar: $sidecar"
|
||||
}
|
||||
|
||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
run: |
|
||||
# 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
|
||||
|
||||
- name: Swap bundle for aarch64
|
||||
run: |
|
||||
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)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
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 || '-' }}
|
||||
with:
|
||||
args: --target aarch64-apple-darwin
|
||||
|
||||
- name: Swap bundle for x86_64
|
||||
run: |
|
||||
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)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
- name: Upload macOS artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# 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 || '-' }}
|
||||
with:
|
||||
args: --target x86_64-apple-darwin
|
||||
|
||||
- name: Upload arm64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-aarch64
|
||||
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
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
||||
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
||||
APP_NAME=$(basename "$ARM_APP")
|
||||
|
||||
mkdir -p universal
|
||||
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
||||
|
||||
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
|
||||
# Wait for the Windows workflow to have created the draft release
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||
echo "Waiting for release to exist... attempt $i"
|
||||
sleep 15
|
||||
done
|
||||
|
||||
- name: Package universal .dmg
|
||||
run: |
|
||||
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
||||
mkdir dmg-stage
|
||||
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"
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload universal .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-universal
|
||||
path: moku-universal.dmg
|
||||
retention-days: 7
|
||||
echo "Found release ID: $RELEASE_ID"
|
||||
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local name="$2"
|
||||
echo "Uploading $name..."
|
||||
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||
}
|
||||
|
||||
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
|
||||
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||
@@ -4,12 +4,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
branch:
|
||||
description: "Branch to build (e.g. svelte-rewrite)"
|
||||
required: false
|
||||
default: "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
@@ -17,8 +16,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -49,8 +46,6 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -86,58 +81,87 @@ jobs:
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
|
||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d)
|
||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f)
|
||||
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
|
||||
|
||||
- name: Extract Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p suwayomi-extracted
|
||||
if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
|
||||
mv "$TOP_DIRS"/* suwayomi-extracted/
|
||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||
cp -r "$INNER"/. suwayomi-extracted/
|
||||
else
|
||||
mv suwayomi-raw/* suwayomi-extracted/
|
||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||
fi
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
|
||||
|
||||
if [ -z "$JAVAW" ]; then
|
||||
echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:"
|
||||
find suwayomi-extracted -type f | head -40
|
||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||
if [ -z "$JAVA" ]; then
|
||||
echo "ERROR: jre/bin/java.exe not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "ERROR: Suwayomi-Server.jar not found"
|
||||
find suwayomi-extracted -type f | head -50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found javaw: $JAVAW"
|
||||
|
||||
# Copy full bundle so jar + jre tree are available at runtime.
|
||||
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir.
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Validate staging
|
||||
shell: bash
|
||||
run: |
|
||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||
echo "Staging OK"
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: 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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: >-
|
||||
--target x86_64-pc-windows-msvc
|
||||
--config '{"bundle":{"resources":["binaries/suwayomi-bundle/**"]}}'
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
releaseBody: |
|
||||
Moku v${{ github.event.inputs.version }}
|
||||
|
||||
- name: Upload Windows installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-windows-x64
|
||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
retention-days: 7
|
||||
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||
**Linux:** Download `moku.flatpak`
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
||||
# --- Flatpak build artifacts ---
|
||||
build-dir/
|
||||
repo/
|
||||
dist/
|
||||
packaging/frontend-dist.tar.gz
|
||||
*.flatpak
|
||||
.flatpak-builder/
|
||||
.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
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright [2026] [@Youwes09]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pkgname=moku
|
||||
pkgver=0.3.0
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Youwes09/Moku"
|
||||
license=('Apache 2.0')
|
||||
url="https://github.com/moku-project/Moku"
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
@@ -18,13 +18,13 @@ makedepends=(
|
||||
'pnpm'
|
||||
)
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/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"
|
||||
"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"
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
|
||||
)
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd "Moku-$pkgver"
|
||||
@@ -33,14 +33,7 @@ prepare() {
|
||||
|
||||
build() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Build frontend
|
||||
pnpm build
|
||||
|
||||
# Repack dist for Tauri
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
|
||||
# Build Tauri binary
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -49,19 +42,12 @@ build() {
|
||||
package() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Moku binary
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
# Bundled JRE
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
# Suwayomi server jar
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
# tachidesk-server wrapper script
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
@@ -76,7 +62,7 @@ server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
|
||||
#!/bin/sh
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
@@ -100,7 +86,7 @@ unset WAYLAND_DISPLAY
|
||||
export _JAVA_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 \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
@@ -109,17 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
|
||||
# Desktop entry and icons
|
||||
install -Dm644 packaging/dev.moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||
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 \
|
||||
"$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 \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -1,137 +1,167 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/rounded-logo.png" width="96" />
|
||||
<h1>Moku</h1>
|
||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||
[](https://github.com/moku-project/Moku/commits/main)
|
||||
[](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>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Reader
|
||||
- **Single**, **double-page**, and **longstrip** reading modes
|
||||
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
|
||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||
- RTL / LTR reading direction toggle
|
||||
- Configurable page gaps
|
||||
- Full keyboard navigation with rebindable keybinds
|
||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
||||
- Auto-mark chapters as read when the last page is reached
|
||||
|
||||
### Library
|
||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
||||
- In-line search
|
||||
- Context menu: open, add/remove from library
|
||||
|
||||
### Series Detail
|
||||
- Cover, author, artist, status badge, genres, and synopsis
|
||||
- Read progress bar with percentage
|
||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
||||
- Sort by newest or oldest first
|
||||
- Jump-to-chapter input
|
||||
- Bulk download menu: from current chapter, unread only, or all
|
||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
||||
- Collapsible source details panel with source ID, language, and source migration
|
||||
|
||||
### Search
|
||||
- Cross-source search running up to 3 concurrent requests
|
||||
- Language filter bar (preferred language default, per-language, or all)
|
||||
- Results grouped by source with skeleton loading states
|
||||
|
||||
### Sources & Extensions
|
||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
||||
- Extension manager: install, update, remove, and install from external APK URL
|
||||
- Repo refresh with update count badge
|
||||
|
||||
### Downloads
|
||||
- Download queue with live progress
|
||||
|
||||
### History
|
||||
- Reading history grouped by day with relative timestamps
|
||||
- Per-entry thumbnail, chapter name, and last-read page
|
||||
- Full-text search across titles and chapter names
|
||||
- One-click clear
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
|
||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||
- **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.)
|
||||
- **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
|
||||
|
||||
**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
|
||||
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:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
**From source**
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/moku
|
||||
cd moku
|
||||
nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> 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
|
||||
|
||||
**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
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
@@ -1,104 +1,39 @@
|
||||
Todo:
|
||||
3. Explore Manga Upscaler & Other Image Processing
|
||||
4. Font Weird on Flatpak, Investigate and Fix
|
||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||
Major Revisions:
|
||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||
- Moku-Share allows exporting of Manga
|
||||
- Compressed Format (Storage)
|
||||
- Import as Local-Source
|
||||
- Takes existing Local-Source or Creates Own
|
||||
|
||||
Minor Revisions:
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
|
||||
|
||||
Bugs:
|
||||
Priority Bugs:
|
||||
- Fix Library-Refresh System (TESTING)
|
||||
|
||||
- Add Back after Search & Clear on Search
|
||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||
- Suwayomi RESET
|
||||
- Allow User to Wipe Suwayomi (Scratch)
|
||||
- If Possible, Component based Wipe (Library, Etc)
|
||||
|
||||
|
||||
- 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
|
||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
||||
skip chapters, etc
|
||||
- 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:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
- Working on 3D Display Cards
|
||||
- Add Flathub Support (Pending Video)
|
||||
|
||||
|
||||
- Fix Downloaded in Library (Tags Broken) & All
|
||||
- Using Delete All Crashes App (But Works)
|
||||
- Fix Folder Display in Library
|
||||
- Add Version Tags (To Find Version)
|
||||
- Sidebar Icon Highlighted
|
||||
- Introduce Deduplication into Library & Search
|
||||
- Fix Tracking Login
|
||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||
|
||||
- Tracking
|
||||
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
||||
|
||||
- Hide Completed from Library Settting
|
||||
|
||||
|
||||
Features:
|
||||
- Add PDF Textbook Support
|
||||
- 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
|
||||
Notes from last time:
|
||||
- Currently working on #42, just need to mount panel and fix button in reader
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-scripts/pkgbuild-bump.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Run this AFTER the git tag has been pushed to GitHub.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-scripts/pkgbuild-bump.sh 0.3.0
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||
|
||||
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||
|
||||
command -v curl &>/dev/null || die "curl not found"
|
||||
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||
|
||||
section "Patching PKGBUILD → ${VERSION}"
|
||||
|
||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v${VERSION}.tar.gz"
|
||||
info "Fetching source tarball to compute 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"
|
||||
|
||||
# Replace only the first sha256 entry (source tarball) inside sha256sums=('...')
|
||||
# The suwayomi jar and jdk hashes are pinned and stay untouched.
|
||||
# Strategy: match the opening sha256sums=('' then swap just that first hash.
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1${TARBALL_SHA}/" "$PKGBUILD"
|
||||
|
||||
# Verify the replacement landed
|
||||
if ! grep -q "$TARBALL_SHA" "$PKGBUILD"; then
|
||||
die "sha256 replacement failed — check PKGBUILD sha256sums format"
|
||||
fi
|
||||
|
||||
success "PKGBUILD patched (pkgver=${VERSION}, sha256=${TARBALL_SHA})"
|
||||
info "PKGBUILD → ${PKGBUILD}"
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-scripts/release.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Usage:
|
||||
# ./build-scripts/release.sh 0.2.0
|
||||
#
|
||||
# Requires: nix, flatpak-builder, appstream
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||
|
||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
||||
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||
|
||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||
section "Pre-flight"
|
||||
command -v nix &>/dev/null || die "nix not found"
|
||||
command -v curl &>/dev/null || die "curl not found"
|
||||
[[ -f "$FLATPAK_MANIFEST" ]] || die "Flatpak manifest not found: $FLATPAK_MANIFEST"
|
||||
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||
success "OK"
|
||||
|
||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
||||
section "Bumping version → ${VERSION}"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
||||
success "tauri.conf.json → ${VERSION}"
|
||||
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
||||
success "Cargo.toml → ${VERSION}"
|
||||
|
||||
# flake.nix has two `version = "x.y.z";` strings inside the frontend
|
||||
# derivation and fetchPnpmDeps — both need to match.
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"${VERSION}\";/g" \
|
||||
"${REPO_ROOT}/flake.nix"
|
||||
success "flake.nix → ${VERSION}"
|
||||
|
||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
||||
section "Building frontend"
|
||||
cd "$REPO_ROOT"
|
||||
nix develop --command pnpm install --frozen-lockfile
|
||||
nix develop --command pnpm build
|
||||
success "Frontend built → dist/"
|
||||
|
||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||
section "Regenerating cargo-sources.json"
|
||||
cd "$REPO_ROOT"
|
||||
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"
|
||||
success "cargo-sources.json updated"
|
||||
|
||||
section "Rebuilding frontend-dist.tar.gz"
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
||||
|
||||
section "Patching frontend-dist sha256 in dev.moku.app.yml"
|
||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
||||
cat > "$PATCH_SCRIPT" << PYEOF
|
||||
import re, sys
|
||||
path = "${FLATPAK_MANIFEST}"
|
||||
new_sha = "${FRONTEND_SHA}"
|
||||
text = open(path).read()
|
||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
||||
replacement = r'\g<1>' + new_sha
|
||||
updated, n = re.subn(pattern, replacement, text)
|
||||
if n == 0:
|
||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
||||
rm -f "$PATCH_SCRIPT"
|
||||
success "dev.moku.app.yml sha256 updated"
|
||||
|
||||
section "Building Flatpak bundle"
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
||||
flatpak-builder \
|
||||
--repo="${REPO_ROOT}/repo" \
|
||||
--force-clean \
|
||||
"${REPO_ROOT}/build-dir" \
|
||||
"$FLATPAK_MANIFEST"
|
||||
|
||||
flatpak build-bundle \
|
||||
"${REPO_ROOT}/repo" \
|
||||
"${REPO_ROOT}/moku.flatpak" \
|
||||
dev.moku.app
|
||||
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
success "moku.flatpak created"
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "v${VERSION} ready"
|
||||
info "Flatpak bundle → ${REPO_ROOT}/moku.flatpak"
|
||||
echo ""
|
||||
warn "PKGBUILD not patched yet — tag must exist on GitHub first."
|
||||
info "After pushing the tag, run:"
|
||||
echo -e " ${CYAN}./build-scripts/pkgbuild-bump.sh ${VERSION}${RESET}"
|
||||
@@ -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 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771438068,
|
||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||
"lastModified": 1773857772,
|
||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -15,32 +15,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -49,53 +33,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-appimage": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1757920913,
|
||||
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"lastModified": 1773821835,
|
||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +51,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -124,7 +68,6 @@
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-appimage": "nix-appimage",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -136,11 +79,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771556776,
|
||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||
"lastModified": 1773975983,
|
||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -148,21 +91,6 @@
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -9,36 +9,27 @@
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nix-appimage = {
|
||||
url = "github:ralismark/nix-appimage";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
|
||||
perSystem =
|
||||
{ system, pkgs, lib, ... }:
|
||||
perSystem = { system, lib, ... }:
|
||||
let
|
||||
pkgs' = import inputs.nixpkgs {
|
||||
version = "0.9.1";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
@@ -65,31 +56,22 @@
|
||||
|| base == "package.json"
|
||||
|| base == "pnpm-lock.yaml"
|
||||
|| base == "tsconfig.json"
|
||||
|| base == "tsconfig.node.json"
|
||||
|| base == "vite.config.ts"
|
||||
|| base == "postcss.config.js"
|
||||
|| base == "postcss.config.cjs"
|
||||
|| base == "tailwind.config.js"
|
||||
|| base == "tailwind.config.ts";
|
||||
|| base == "vite.config.ts";
|
||||
};
|
||||
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
version = "0.3.0";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
version = "0.3.0";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
@@ -111,10 +93,7 @@
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
@@ -126,6 +105,36 @@
|
||||
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
|
||||
@@ -134,72 +143,184 @@
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_FORCE_SANDBOX 0
|
||||
|
||||
# ── Icon ─────────────────────────────────────────────────────────
|
||||
# Tauri bakes several sizes into src-tauri/icons/. We prefer the
|
||||
# largest PNG (512x512) for the hicolor theme, and also install the
|
||||
# rounded 32x32 used as the in-app logo so small sizes look right.
|
||||
# Adjust the source filenames if yours differ.
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
if [ -f "$src" ]; then
|
||||
install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
fi
|
||||
done
|
||||
|
||||
# @2x variants that Tauri also generates
|
||||
for size in 128x128 256x256; do
|
||||
src="icons/''${size}@2x.png"
|
||||
if [ -f "$src" ]; then
|
||||
install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
fi
|
||||
done
|
||||
|
||||
# Scalable SVG — src/assets/moku-icon.svg is the rounded version
|
||||
# referenced in SplashScreen.tsx. Pull it straight from the source
|
||||
# tree so the launcher always uses the same rounded artwork.
|
||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
# ── .desktop entry ───────────────────────────────────────────────
|
||||
install -Dm644 /dev/stdin \
|
||||
"$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
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
});
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
|
||||
nodejs_22 pnpm
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "── Bumping version fields to $VERSION ──"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||
echo "Done"
|
||||
|
||||
echo "── Regenerating Cargo.lock ──"
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
echo "Done"
|
||||
|
||||
echo "── Building frontend ──"
|
||||
cd "$REPO"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
echo "Done"
|
||||
|
||||
echo "── Repacking frontend-dist.tar.gz ──"
|
||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
echo "sha256: $FRONTEND_SHA"
|
||||
|
||||
echo "── Regenerating cargo-sources.json ──"
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
echo "Done"
|
||||
|
||||
echo "── Patching flatpak manifest (version + frontend sha256) ──"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
updated, n = re.subn(
|
||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||
r'\g<1>' + sha, text)
|
||||
if n == 0:
|
||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
echo "Done"
|
||||
|
||||
echo ""
|
||||
echo "Bumped to v$VERSION"
|
||||
echo ""
|
||||
echo "Commit field in the flatpak manifest still points to the old tag."
|
||||
echo "After pushing the tag, run:"
|
||||
echo " nix run .#post-tag-bump -- $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
postTagBumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-post-tag-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
|
||||
echo "── Resolving commit for v$VERSION ──"
|
||||
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
||||
| awk '{print $1}')
|
||||
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||
echo "commit: $COMMIT"
|
||||
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||
echo "Done"
|
||||
|
||||
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||
echo "Done"
|
||||
|
||||
echo ""
|
||||
echo "post-tag-bump complete for v$VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
flatpakScript = pkgs.writeShellApplication {
|
||||
name = "moku-flatpak";
|
||||
runtimeInputs = with pkgs; [
|
||||
gnused coreutils git
|
||||
appstream flatpak-builder flatpak
|
||||
];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
|
||||
echo "── Building flatpak for v$VERSION ──"
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$MANIFEST"
|
||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
|
||||
echo ""
|
||||
echo "moku.flatpak created — v$VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-pkgbuild-bump";
|
||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||
|
||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||
echo "Fetching tarball sha256..."
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||
|
||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
||||
|
||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
||||
'';
|
||||
};
|
||||
|
||||
tunnelScript = pkgs.writeShellApplication {
|
||||
name = "moku-tunnel";
|
||||
runtimeInputs = with pkgs; [ cloudflared ];
|
||||
text = ''
|
||||
PORT="''${1:-4567}"
|
||||
cloudflared tunnel --url "http://localhost:$PORT"
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# Expose as both a runnable app and installable packages.
|
||||
apps = {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${moku}/bin/moku";
|
||||
};
|
||||
moku = {
|
||||
type = "app";
|
||||
program = "${moku}/bin/moku";
|
||||
};
|
||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
|
||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = moku;
|
||||
appimage = nix-appimage.bundlers."${system}".default moku;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -211,29 +332,23 @@
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
cloudflared
|
||||
xdg-utils
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||
];
|
||||
shellHook = ''
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
export NO_STRIP=true
|
||||
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}"
|
||||
|
||||
if [ ! -e /usr/bin/xdg-open ]; then
|
||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
||||
fi
|
||||
|
||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
||||
chmod +x "$LINUXDEPLOY"
|
||||
echo "linuxdeploy wrapped with appimage-run"
|
||||
fi
|
||||
|
||||
echo "Moku dev shell"
|
||||
echo " pnpm install && pnpm tauri:dev"
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo "Release workflow:"
|
||||
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
|
||||
echo " git commit && git tag && git push"
|
||||
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
|
||||
echo " nix run .#flatpak -- <ver> build moku.flatpak"
|
||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Moku</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: dev.moku.app
|
||||
app-id: io.github.moku_project.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -9,16 +9,22 @@ separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --socket=session-bus
|
||||
- --socket=system-bus
|
||||
- --filesystem=home
|
||||
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --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
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-download
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
@@ -33,13 +39,10 @@ modules:
|
||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||
sources:
|
||||
- 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
|
||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||
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: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||
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
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
@@ -120,7 +123,6 @@ modules:
|
||||
fi
|
||||
|
||||
# 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 \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
@@ -138,8 +140,6 @@ modules:
|
||||
export _JAVA_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"
|
||||
|
||||
exec /app/jre/bin/java \
|
||||
@@ -171,17 +171,19 @@ modules:
|
||||
- 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
|
||||
- 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 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.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.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 src-tauri/icons/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/io.github.moku_project.Moku.png
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: git
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.9.1
|
||||
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: c9bb5ee6613b2bc61e69a92cc1ef0029da3b61138d51b01d363f8ea524e51996
|
||||
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
@@ -1,41 +1,31 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "tauri build"
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^5.0.0"
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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.1.0" date="2025-01-01">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=dev.moku.app
|
||||
Icon=io.github.moku_project.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
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,11 +1,11 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.3.0"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "moku_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "moku"
|
||||
@@ -17,11 +17,19 @@ tauri-build = { version = "2.0", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2.3.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"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# Moku — 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}/moku/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 = 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 = []
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||
-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 = 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"
|
||||
|
||||
# ── 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,20 +1,40 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Allow launching suwayomi-server sidecar",
|
||||
"description": "Default permissions for Moku",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-kill",
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "binaries/suwayomi-server",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"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://*/*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,8 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::io::Write;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -16,17 +19,43 @@ pub struct StorageInfo {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum SpawnError {
|
||||
NotConfigured(String),
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
return PathBuf::from(downloads_path.trim());
|
||||
}
|
||||
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")
|
||||
suwayomi_data_dir().join("downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -45,7 +74,9 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() { path.clone() } else {
|
||||
let stat_path = if path.exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
|
||||
@@ -56,47 +87,111 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
|
||||
let total_bytes = disk.total_space();
|
||||
let free_bytes = disk.available_space();
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
total_bytes: disk.total_space(),
|
||||
free_bytes: disk.available_space(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the true OS-level scale factor for the main window.
|
||||
/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio.
|
||||
/// On macOS the value comes directly from the native window.
|
||||
#[tauri::command]
|
||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||
fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use std::fs;
|
||||
|
||||
let src_path = std::path::PathBuf::from(src.trim());
|
||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64;
|
||||
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||
let target = dst_path.join(rel);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||
done += 1;
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(child) = guard.take() {
|
||||
if let Some(child) = state.0.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
println!("Killed tracked server child.");
|
||||
}
|
||||
|
||||
#[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 tachidesk*"])
|
||||
.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")
|
||||
.arg("-f")
|
||||
.arg("tachidesk")
|
||||
.status();
|
||||
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||
}
|
||||
|
||||
/// The default server.conf we seed on first launch.
|
||||
/// Mirrors the Flatpak wrapper: headless, no tray, no browser pop-up.
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
@@ -114,9 +209,6 @@ server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
/// Ensure the Suwayomi data dir and server.conf exist, and that the three
|
||||
/// keys that cause GUI/JCEF crashes are always set to safe values.
|
||||
/// This mirrors the shell-script logic in the Flatpak's tachidesk-server wrapper.
|
||||
fn seed_server_conf(data_dir: &PathBuf) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
@@ -131,216 +223,613 @@ fn seed_server_conf(data_dir: &PathBuf) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conf already exists — patch the three critical keys in-place.
|
||||
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",
|
||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
||||
"server.initialOpenInBrowserEnabled", "false",
|
||||
),
|
||||
"server.initialOpenInBrowserEnabled",
|
||||
"false",
|
||||
),
|
||||
"server.systemTrayEnabled",
|
||||
"false",
|
||||
"server.systemTrayEnabled", "false",
|
||||
);
|
||||
|
||||
let _ = std::fs::write(&conf_path, patched);
|
||||
}
|
||||
|
||||
/// Replace `key = <value>` in a HOCON/properties-style conf, or append it
|
||||
/// if the key is absent.
|
||||
fn patch_conf_key(mut text: String, key: &str, value: &str) -> String {
|
||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||
let replacement = format!("{key} = {value}");
|
||||
// Find a line that starts with the key (tolerant of surrounding whitespace)
|
||||
if let Some(pos) = text.lines().position(|l| l.trim_start().starts_with(key)) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// We need an owned replacement; rebuild from scratch.
|
||||
let owned: Vec<String> = lines
|
||||
|
||||
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.clone() } else { l.to_string() }
|
||||
})
|
||||
.collect();
|
||||
return owned.join("\n");
|
||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
// Key absent — append.
|
||||
if !text.ends_with('\n') { text.push('\n'); }
|
||||
text.push_str(&replacement);
|
||||
text.push('\n');
|
||||
text
|
||||
|
||||
let mut out = text;
|
||||
if !out.ends_with('\n') { out.push('\n'); }
|
||||
out.push_str(&replacement);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve the Suwayomi data directory.
|
||||
///
|
||||
/// - Linux: $XDG_DATA_HOME/moku/tachidesk (matches Flatpak path)
|
||||
/// - macOS: ~/Library/Application Support/dev.moku.app/tachidesk
|
||||
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")
|
||||
.join("io.github.moku_project.Moku.app/tachidesk")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[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"))
|
||||
});
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||
base.join("moku/tachidesk")
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to spawn the server process.
|
||||
struct ServerInvocation {
|
||||
/// Path to the executable (javaw.exe on Windows, the sidecar script on macOS/Linux).
|
||||
bin: std::ffi::OsString,
|
||||
/// Extra args prepended before the Suwayomi rootDir flag.
|
||||
/// On Windows: ["-jar", "<path-to-jar>"]
|
||||
/// Elsewhere: []
|
||||
prefix_args: Vec<String>,
|
||||
/// Working directory for the child process.
|
||||
/// On Windows this must be the bundle folder so javaw can find the JRE and jar.
|
||||
/// Elsewhere: None (inherit).
|
||||
bin: String,
|
||||
args: Vec<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolve the server binary path.
|
||||
///
|
||||
/// If the frontend passes a non-empty `binary` string (user override in
|
||||
/// Settings) we always use that — on Linux this is the nixpkgs/Flatpak path.
|
||||
///
|
||||
/// Otherwise we look for the Tauri-bundled sidecar inside the resource dir
|
||||
/// and, on Windows, build the javaw + jar invocation from the suwayomi-bundle.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
|
||||
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||
eprintln!("{}", msg);
|
||||
if let Some(f) = log {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_server_binary(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
) -> Result<ServerInvocation, String> {
|
||||
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: std::ffi::OsString::from(binary),
|
||||
prefix_args: vec![],
|
||||
working_dir: None,
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
|
||||
|
||||
// ── Windows: invoke the bundled javaw.exe with -jar Suwayomi-Launcher.jar ──
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let sidecar = resource_dir.join("suwayomi-server-x86_64-pc-windows-msvc.exe");
|
||||
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||
let jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||
|
||||
if sidecar.exists() && jar.exists() {
|
||||
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: sidecar.into_os_string(),
|
||||
prefix_args: vec![
|
||||
"-jar".to_string(),
|
||||
jar.to_string_lossy().into_owned(),
|
||||
],
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
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".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── macOS / Linux: sidecar script is self-contained ──
|
||||
let candidates = [
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
// plain name as a dev/Linux fallback
|
||||
"suwayomi-server",
|
||||
];
|
||||
|
||||
for name in &candidates {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
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.into_os_string(),
|
||||
prefix_args: vec![],
|
||||
working_dir: None,
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(resource_dir.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err("Suwayomi server binary not found. Please set the path in Settings.".to_string())
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let contents_dir = resource_dir
|
||||
.parent()
|
||||
.unwrap_or(&resource_dir)
|
||||
.to_path_buf();
|
||||
|
||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
||||
|
||||
const NATIVE_NAMES: &[&str] = &[
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
|
||||
let mut found_binary: Option<ServerInvocation> = None;
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||
|
||||
'outer: for depth in 0u8..=8 {
|
||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||
.min_depth(depth as usize)
|
||||
.max_depth(depth as usize)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_dir())
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
for dir in &entries {
|
||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
||||
|
||||
for name in NATIVE_NAMES {
|
||||
let p = dir.join(name);
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||
found_binary = Some(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(dir.clone()),
|
||||
});
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if found_java.is_none() {
|
||||
let java_exe = dir.join("bin").join("java");
|
||||
if java_exe.exists() {
|
||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||
let mut search = dir.as_path();
|
||||
'jar: for _ in 0..5 {
|
||||
if let Ok(rd) = std::fs::read_dir(search) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bin_sibling = search.join("bin");
|
||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
match search.parent() {
|
||||
Some(p) => search = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(inv) = found_binary {
|
||||
return Ok(inv);
|
||||
}
|
||||
|
||||
if let Some((java, jar)) = found_java {
|
||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir,
|
||||
});
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
||||
}
|
||||
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
||||
}
|
||||
}
|
||||
|
||||
Err(SpawnError::NotConfigured(
|
||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<ServerState>();
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||
{
|
||||
let guard = state.0.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
println!("Server already running, skipping spawn.");
|
||||
let state = app.state::<ServerState>();
|
||||
if state.0.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Seed server.conf before launching so Suwayomi starts in headless mode.
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||
|
||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
||||
|
||||
seed_server_conf(&data_dir);
|
||||
|
||||
let invocation = resolve_server_binary(&binary, &app)?;
|
||||
let shell = app.shell();
|
||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||
e
|
||||
})?;
|
||||
|
||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
}
|
||||
|
||||
// Build the full arg list: prefix_args (e.g. -jar foo.jar) + rootDir flag.
|
||||
let args: Vec<String> = invocation.prefix_args.into_iter().chain(std::iter::once(rootdir_flag)).collect();
|
||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
// On Windows, set the working directory to the bundle folder so javaw.exe
|
||||
// can resolve the JRE and jar relative paths correctly.
|
||||
let cmd = shell
|
||||
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||
|
||||
let cmd = app.shell()
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&args)
|
||||
.current_dir(invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()));
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
println!("Spawned server: {:?}", invocation.bin);
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn {:?}: {}", invocation.bin, e);
|
||||
Err(e.to_string())
|
||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
kill_tachidesk(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri_plugin_http::reqwest;
|
||||
use std::io::Write;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Asset { name: String, browser_download_url: String, size: u64 }
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Release { assets: Vec<Asset> }
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
let asset = release.assets
|
||||
.into_iter()
|
||||
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||
|
||||
let total = if asset.size > 0 { Some(asset.size) } else { None };
|
||||
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
downloaded += chunk.len() as u64;
|
||||
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||
}
|
||||
drop(file);
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
std::process::Command::new(&tmp_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("update-launching", ());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let p = strip_unc(std::path::PathBuf::from(path.trim()));
|
||||
std::process::Command::new("explorer")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose Downloads Folder")
|
||||
.blocking_pick_folder()
|
||||
.map(|p| p.to_string())
|
||||
}
|
||||
|
||||
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
app.path().app_data_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let filename = format!("moku-backup-{}.json", now);
|
||||
|
||||
let path = app.dialog()
|
||||
.file()
|
||||
.set_title("Save Moku app data backup")
|
||||
.set_file_name(&filename)
|
||||
.blocking_save_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
let dest = PathBuf::from(path.to_string());
|
||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(dest.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let path = app.dialog()
|
||||
.file()
|
||||
.set_title("Open Moku app data backup")
|
||||
.blocking_pick_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
let src = PathBuf::from(path.to_string());
|
||||
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
||||
let backup_dir = moku_backup_dir(&app);
|
||||
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
|
||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||
let _ = std::fs::remove_file(old.path());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||
moku_backup_dir(&app).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_discord_rpc::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
get_default_downloads_path,
|
||||
check_path_exists,
|
||||
create_directory,
|
||||
migrate_downloads,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_scale_factor,
|
||||
get_platform_ui_scale,
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
open_path,
|
||||
pick_downloads_folder,
|
||||
export_app_data,
|
||||
import_app_data,
|
||||
auto_backup_app_data,
|
||||
get_auto_backup_dir,
|
||||
])
|
||||
.setup(|_app| Ok(()))
|
||||
.on_window_event(|window, event| {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.3.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"version": "0.9.1",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
@@ -17,7 +17,8 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
"decorations": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -26,14 +27,22 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage"],
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"devtools": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"externalBin": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||
import { applyTheme } from "@core/theme";
|
||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||
import { checkForUpdateSilently } from "@core/updater";
|
||||
import Layout from "@shared/chrome/Layout.svelte";
|
||||
import Reader from "@features/reader/components/Reader.svelte";
|
||||
import Settings from "@features/settings/components/Settings.svelte";
|
||||
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
void platform();
|
||||
|
||||
let appReady = $state(false);
|
||||
let idle = $state(false);
|
||||
let devSplash = $state(false);
|
||||
|
||||
let themeEditorOpen = $state(false);
|
||||
let themeEditorEditId = $state<string | null>(null);
|
||||
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorEditId = id ?? null;
|
||||
themeEditorOpen = true;
|
||||
}
|
||||
|
||||
function closeThemeEditor() {
|
||||
themeEditorOpen = false;
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const next = downloadStore.queue.slice();
|
||||
downloadStore.detectTransitions(next);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
||||
|
||||
applyZoom();
|
||||
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
store.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
const unlistenScale = await win.onScaleChanged(async () => {
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||
else console.warn("Could not start server:", err);
|
||||
});
|
||||
}
|
||||
|
||||
startProbe();
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
"download-progress",
|
||||
e => setActiveDownloads(e.payload),
|
||||
);
|
||||
|
||||
await downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
|
||||
return () => {
|
||||
stopProbe();
|
||||
clearInterval(dlInterval);
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
destroyRpc();
|
||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
|
||||
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={retryBoot}
|
||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||
|
||||
{:else if boot.unsupportedMode || boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { appReady = true; }} />
|
||||
|
||||
{:else}
|
||||
{#if idle && !store.activeChapter}
|
||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => { idle = false; }} />
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
||||
{#if themeEditorOpen}
|
||||
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
||||
{/if}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
</style>
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import MangaPreview from "./components/explore/MangaPreview";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import Toaster from "./components/layout/Toaster";
|
||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import s from "./App.module.css";
|
||||
|
||||
const MAX_ATTEMPTS = 30;
|
||||
|
||||
export default function App() {
|
||||
const activeChapter = useStore((s) => s.activeChapter);
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
|
||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||
// appReady = ring filled + transition done, show main UI
|
||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [idle, setIdle] = useState(false);
|
||||
// dev tools: force show splash
|
||||
const [devSplash, setDevSplash] = useState(false);
|
||||
|
||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const idleRef = useRef(false);
|
||||
|
||||
// expose devSplash trigger via window for settings
|
||||
useEffect(() => {
|
||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||
return () => { delete (window as any).__mokuShowSplash; };
|
||||
}, []);
|
||||
|
||||
// Keep idleRef in sync so resetIdle can check it without a stale closure
|
||||
useEffect(() => { idleRef.current = idle; }, [idle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function resetIdle() {
|
||||
// While the idle splash is visible, don't reset — let SplashScreen's own
|
||||
// dismiss flow handle teardown so the exit animation plays fully.
|
||||
if (idleRef.current) return;
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (idleTimeoutMs === 0) return;
|
||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||
}
|
||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||
resetIdle();
|
||||
return () => {
|
||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
}, [appReady, settings.idleTimeoutMin]);
|
||||
|
||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||
for (const item of prev) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||
const manga = item.chapter.manga;
|
||||
addToast({ kind:"success", title:"Chapter downloaded",
|
||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||
duration: 4000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueue(next: DownloadQueueItem[]) {
|
||||
detectCompletions(prevQueueRef.current, next);
|
||||
prevQueueRef.current = next;
|
||||
setActiveDownloads(next.map(item => ({
|
||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = settings.theme ?? "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const p = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", p);
|
||||
return () => document.removeEventListener("contextmenu", p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoStartServer) return;
|
||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||
console.warn("Could not start server:", err));
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Poll until server responds
|
||||
useEffect(() => {
|
||||
if (serverProbeOk) return;
|
||||
let cancelled = false, tries = 0;
|
||||
async function probe() {
|
||||
if (cancelled) return;
|
||||
tries++;
|
||||
try {
|
||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ query:"{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||
if (!cancelled) setTimeout(probe, 800);
|
||||
}
|
||||
const t = setTimeout(probe, 800);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
}
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [appReady]);
|
||||
|
||||
useEffect(() => {
|
||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||
return () => { unsub.then(fn => fn()); };
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||
if (devSplash) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showFps
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading splash — shown until ring fills + transition completes
|
||||
if (!appReady) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
ringFull={serverProbeOk}
|
||||
failed={failed}
|
||||
showCards={settings.splashCards ?? true}
|
||||
onReady={() => setAppReady(true)}
|
||||
onRetry={() => {
|
||||
setFailed(false);
|
||||
setServerProbeOk(false);
|
||||
setRetryKey(k => k+1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{idle && !activeChapter && (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
)}
|
||||
{!activeChapter && <TitleBar/>}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader/> : <Layout/>}
|
||||
</div>
|
||||
{settingsOpen && <Settings/>}
|
||||
<MangaPreview/>
|
||||
<Toaster/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { fetchAuthenticated } from "../core/auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
function getServerUrl(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const url = parsed?.state?.settings?.serverUrl;
|
||||
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_URL;
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
export function plainThumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
export const thumbUrl = plainThumbUrl;
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||
@@ -37,12 +32,6 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper with these guarantees:
|
||||
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
||||
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
||||
* 3. If the signal is already aborted before we even start, we bail instantly.
|
||||
*/
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
@@ -50,29 +39,17 @@ async function fetchWithRetry(
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
// Bail immediately if already aborted before we start
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
// Check abort at the top of every iteration
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
|
||||
// Check abort again — fetch can return a response even after abort in some runtimes
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
// Never retry aborted requests
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// Last retry — give up
|
||||
if (e?.authRequired) throw e;
|
||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (i === retries - 1) throw e;
|
||||
|
||||
// Abort-aware delay between retries
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
}
|
||||
@@ -84,20 +61,15 @@ export async function gql<T>(
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
||||
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) throw new Error(json.errors[0].message);
|
||||
|
||||
return json.data;
|
||||
}
|
||||
@@ -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,48 @@
|
||||
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 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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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,89 @@
|
||||
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 INSTALL_EXTERNAL_EXTENSION = `
|
||||
mutation InstallExternalExtension($url: String!) {
|
||||
installExternalExtension(input: { extensionUrl: $url }) {
|
||||
extension { apkName pkgName name isInstalled }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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,91 @@
|
||||
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 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 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_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,450 @@
|
||||
# Mutations
|
||||
|
||||
## Manga (`mutations/manga.ts`)
|
||||
|
||||
### `FETCH_MANGA`
|
||||
Fetches and refreshes manga metadata from its source.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGA`
|
||||
Updates a single manga's library membership.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGAS`
|
||||
Bulk-updates library membership for multiple manga.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Manga IDs |
|
||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGA_CATEGORIES`
|
||||
Adds or removes a manga from categories.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| `addTo` | `[Int!]!` | Category IDs to add to |
|
||||
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
||||
|
||||
---
|
||||
|
||||
### `CREATE_CATEGORY`
|
||||
Creates a new manga category.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `name` | `String!` | Category name |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CATEGORY`
|
||||
Updates a category's name.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
| `name` | `String` | New name |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE_CATEGORY`
|
||||
Deletes a category by ID.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CATEGORY_ORDER`
|
||||
Moves a category to a new position.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
| `position` | `Int!` | New position index |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_LIBRARY`
|
||||
Triggers a library-wide metadata refresh and returns job status.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `CREATE_BACKUP`
|
||||
Creates a backup and returns its download URL.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `RESTORE_BACKUP`
|
||||
Restores a backup from an uploaded file and returns restore job status.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `backup` | `Upload!` | Backup file |
|
||||
|
||||
---
|
||||
|
||||
## Chapters (`mutations/chapters.ts`)
|
||||
|
||||
### `FETCH_CHAPTERS`
|
||||
Fetches/refreshes the chapter list for a manga from its source.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_CHAPTER_PAGES`
|
||||
Fetches the page URLs for a specific chapter.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `MARK_CHAPTER_READ`
|
||||
Marks a single chapter as read or unread.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Chapter ID |
|
||||
| `isRead` | `Boolean!` | Read state |
|
||||
|
||||
---
|
||||
|
||||
### `MARK_CHAPTERS_READ`
|
||||
Bulk-marks multiple chapters as read or unread.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
| `isRead` | `Boolean!` | Read state |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CHAPTERS_PROGRESS`
|
||||
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
| `isRead` | `Boolean` | Read state |
|
||||
| `isBookmarked` | `Boolean` | Bookmark state |
|
||||
| `lastPageRead` | `Int` | Last page index read |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE_DOWNLOADED_CHAPTERS`
|
||||
Deletes downloaded chapter files for the given chapter IDs.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
|
||||
---
|
||||
|
||||
## Downloads (`mutations/downloads.ts`)
|
||||
|
||||
### `ENQUEUE_DOWNLOAD`
|
||||
Adds a single chapter to the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
||||
Adds multiple chapters to the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
||||
|
||||
---
|
||||
|
||||
### `DEQUEUE_DOWNLOAD`
|
||||
Removes a chapter from the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `START_DOWNLOADER`
|
||||
Starts the downloader and returns the current queue state.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `STOP_DOWNLOADER`
|
||||
Stops the downloader and returns the current queue state.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `CLEAR_DOWNLOADER`
|
||||
Clears all items from the download queue.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_SOURCE_MANGA`
|
||||
Fetches manga from a source (browse/search), with pagination and optional filters.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `source` | `LongString!` | Source ID |
|
||||
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
||||
| `page` | `Int!` | Page number |
|
||||
| `query` | `String` | Search query |
|
||||
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
||||
|
||||
---
|
||||
|
||||
### `SET_DOWNLOADS_PATH`
|
||||
Sets the downloads directory path in settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `path` | `String!` | Filesystem path |
|
||||
|
||||
---
|
||||
|
||||
### `SET_LOCAL_SOURCE_PATH`
|
||||
Sets the local source directory path in settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `path` | `String!` | Filesystem path |
|
||||
|
||||
---
|
||||
|
||||
## Extensions (`mutations/extensions.ts`)
|
||||
|
||||
### `FETCH_EXTENSIONS`
|
||||
Fetches the latest extension list from configured repos.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_EXTENSION`
|
||||
Installs, uninstalls, or updates an extension.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `String!` | Extension package name |
|
||||
| `install` | `Boolean` | Install the extension |
|
||||
| `uninstall` | `Boolean` | Uninstall the extension |
|
||||
| `update` | `Boolean` | Update the extension |
|
||||
|
||||
---
|
||||
|
||||
### `INSTALL_EXTERNAL_EXTENSION`
|
||||
Installs an extension from an external APK URL.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `url` | `String!` | APK download URL |
|
||||
|
||||
---
|
||||
|
||||
### `SET_EXTENSION_REPOS`
|
||||
Sets the list of extension repository URLs.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `repos` | `[String!]!` | Repository URLs |
|
||||
|
||||
---
|
||||
|
||||
### `SET_SERVER_AUTH`
|
||||
Configures server authentication mode and credentials.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `authMode` | `AuthMode!` | Auth mode |
|
||||
| `authUsername` | `String!` | Username |
|
||||
| `authPassword` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `SET_SOCKS_PROXY`
|
||||
Configures SOCKS proxy settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
||||
| `socksProxyHost` | `String!` | Proxy host |
|
||||
| `socksProxyPort` | `String!` | Proxy port |
|
||||
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
||||
| `socksProxyUsername` | `String!` | Proxy username |
|
||||
| `socksProxyPassword` | `String!` | Proxy password |
|
||||
|
||||
---
|
||||
|
||||
### `SET_FLARESOLVERR`
|
||||
Configures FlareSolverr integration settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
||||
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
||||
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
||||
| `flareSolverrSessionName` | `String!` | Session name |
|
||||
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
||||
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
||||
|
||||
---
|
||||
|
||||
## Tracking (`mutations/tracking.ts`)
|
||||
|
||||
### `BIND_TRACK`
|
||||
Binds a manga to a remote tracker entry.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_TRACK`
|
||||
Updates tracking progress, status, score, and dates for a track record.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
| `status` | `Int` | Reading status |
|
||||
| `lastChapterRead` | `Float` | Last chapter read |
|
||||
| `scoreString` | `String` | Score in tracker's format |
|
||||
| `startDate` | `LongString` | Start date |
|
||||
| `finishDate` | `LongString` | Finish date |
|
||||
| `private` | `Boolean` | Mark as private |
|
||||
|
||||
---
|
||||
|
||||
### `UNBIND_TRACK`
|
||||
Unbinds a manga from a tracker record.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_TRACK`
|
||||
Refreshes a track record from the remote tracker.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_TRACKER_OAUTH`
|
||||
Initiates OAuth login for a tracker using a callback URL.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `callbackUrl` | `String!` | OAuth callback URL |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_TRACKER_CREDENTIALS`
|
||||
Logs into a tracker using username and password.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `username` | `String!` | Username |
|
||||
| `password` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `LOGOUT_TRACKER`
|
||||
Logs out of a tracker.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_USER`
|
||||
Authenticates a user and returns access and refresh tokens.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `username` | `String!` | Username |
|
||||
| `password` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `REFRESH_TOKEN`
|
||||
Refreshes the current access token.
|
||||
|
||||
**Variables:** none
|
||||
@@ -0,0 +1,80 @@
|
||||
const TRACK_RECORD_FRAGMENT = `
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||
`;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
tracker { id name isLoggedIn 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 authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
tracker { id name isLoggedIn authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken { 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 scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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,5 @@
|
||||
export * from "./manga";
|
||||
export * from "./chapters";
|
||||
export * from "./downloads";
|
||||
export * from "./extensions";
|
||||
export * from "./tracking";
|
||||
@@ -0,0 +1,96 @@
|
||||
export const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount
|
||||
description status author artist genre
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
source { id name displayName }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 }
|
||||
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,171 @@
|
||||
# Queries
|
||||
|
||||
## Manga (`queries/manga.ts`)
|
||||
|
||||
### `GET_LIBRARY`
|
||||
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_ALL_MANGA`
|
||||
Fetches all manga (library and non-library) with minimal fields.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_MANGA`
|
||||
Fetches a single manga by ID with full metadata and source info.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `GET_CATEGORIES`
|
||||
Fetches all categories with their order, settings, and the manga assigned to each.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
||||
Fetches page counts for all downloaded chapters.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_DOWNLOADS_PATH`
|
||||
Fetches the configured downloads path and local source path from settings.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `LIBRARY_UPDATE_STATUS`
|
||||
Fetches the current library update job status, including progress and any manga with new chapters.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_RESTORE_STATUS`
|
||||
Fetches the status of a backup restore operation by its job ID.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `String!` | Restore job ID |
|
||||
|
||||
---
|
||||
|
||||
### `VALIDATE_BACKUP`
|
||||
Validates a backup file and returns any missing sources or trackers.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `backup` | `Upload!` | Backup file |
|
||||
|
||||
---
|
||||
|
||||
## Chapters (`queries/chapters.ts`)
|
||||
|
||||
### `GET_CHAPTERS`
|
||||
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
## Downloads (`queries/downloads.ts`)
|
||||
|
||||
### `GET_DOWNLOAD_STATUS`
|
||||
Fetches the current downloader state and full queue with chapter and manga info.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
## Extensions (`queries/extensions.ts`)
|
||||
|
||||
### `GET_EXTENSIONS`
|
||||
Fetches all extensions with install status, update availability, and metadata.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SOURCES`
|
||||
Fetches all available sources with language and NSFW flags.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SETTINGS`
|
||||
Fetches extension repository settings.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SERVER_SECURITY`
|
||||
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
## Tracking (`queries/tracking.ts`)
|
||||
|
||||
### `GET_TRACKERS`
|
||||
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_MANGA_TRACK_RECORDS`
|
||||
Fetches all tracking records for a specific manga across all trackers.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `SEARCH_TRACKER`
|
||||
Searches a tracker for manga by query string.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `query` | `String!` | Search query |
|
||||
|
||||
---
|
||||
|
||||
### `GET_ALL_TRACKER_RECORDS`
|
||||
Fetches all trackers and their full track records, including associated manga info.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_TRACKER_RECORDS`
|
||||
Fetches track records for a specific tracker.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
@@ -0,0 +1,69 @@
|
||||
export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn authUrl supportsPrivateTracking 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 scores
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId title status displayScore lastChapterRead
|
||||
totalChapters remoteUrl private
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -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 |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<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 |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<g transform="translate(256,265) 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,27 +1,22 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||
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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,83 +0,0 @@
|
||||
.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);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled),
|
||||
.itemFocused:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon area — fixed-width column so labels align */
|
||||
.itemIconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:hover .itemIconWrap,
|
||||
.itemFocused .itemIconWrap {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Danger variant */
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled),
|
||||
.itemDanger.itemFocused:not(:disabled) {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
||||
|
||||
/* Disabled */
|
||||
.itemDisabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 3px var(--sp-1);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import s from "./ContextMenu.module.css";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: never;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
separator: true;
|
||||
label?: never;
|
||||
icon?: never;
|
||||
onClick?: never;
|
||||
danger?: never;
|
||||
disabled?: never;
|
||||
}
|
||||
|
||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState<number>(-1);
|
||||
|
||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
||||
const actionable = items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && focused >= 0) {
|
||||
e.preventDefault();
|
||||
const item = items[focused] as ContextMenuItem;
|
||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose, focused, actionable, items]);
|
||||
|
||||
// Focus first item on open
|
||||
useEffect(() => {
|
||||
if (actionable.length) setFocused(actionable[0]);
|
||||
}, []);
|
||||
|
||||
const getPosition = useCallback(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||
const scaledX = x / zoom;
|
||||
const scaledY = y / zoom;
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 34;
|
||||
const vw = window.innerWidth / zoom;
|
||||
const vh = window.innerHeight / zoom;
|
||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
||||
}, [x, y, items.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={getPosition()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if ("separator" in item && item.separator) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
const isFocused = focused === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[
|
||||
s.item,
|
||||
mi.danger ? s.itemDanger : "",
|
||||
mi.disabled ? s.itemDisabled : "",
|
||||
isFocused ? s.itemFocused : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||
onMouseLeave={() => setFocused(-1)}
|
||||
disabled={mi.disabled}
|
||||
>
|
||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||
{mi.icon ?? null}
|
||||
</span>
|
||||
<span className={s.itemLabel}>{mi.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.headerActions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.iconBtn {
|
||||
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);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
/* Loading state — accent tint so it's visually distinct */
|
||||
.iconBtnLoading {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.iconBtnLoading:hover:not(:disabled) {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
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);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
|
||||
.statusDotActive {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.6s ease infinite;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
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);
|
||||
}
|
||||
|
||||
.rowActive { border-color: var(--accent-dim); }
|
||||
|
||||
/* Fade out rows being removed */
|
||||
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Thumbnail */
|
||||
.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);
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Info block */
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mangaTitle {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pagesLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Right side */
|
||||
.rowRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stateLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
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);
|
||||
}
|
||||
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.removeBtn: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);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
// Apply status to local state + global store.
|
||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||
setStatus(ds);
|
||||
setActiveDownloads(
|
||||
ds.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}))
|
||||
);
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
setTogglingPlay(true);
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||
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 {
|
||||
setTogglingPlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||
setStatus((prev) =>
|
||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||
);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
poll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setDequeueing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(chapterId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queue = status?.queue ?? [];
|
||||
const isRunning = status?.state === "STARTED";
|
||||
|
||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||
return Math.round(progress * pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||
title={isRunning ? "Pause" : "Resume"}
|
||||
>
|
||||
{togglingPlay ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : isRunning ? (
|
||||
<Pause size={14} weight="fill" />
|
||||
) : (
|
||||
<Play size={14} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={clear}
|
||||
disabled={clearing || queue.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
{clearing ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : (
|
||||
<Trash size={14} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.statusBar}>
|
||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||
<span className={s.statusText}>
|
||||
{togglingPlay
|
||||
? (isRunning ? "Pausing…" : "Starting…")
|
||||
: isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<span className={s.statusCount}>{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className={s.empty}>Queue is empty.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isRemoving = dequeueing.has(item.chapter.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.chapter.id}
|
||||
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||
>
|
||||
{manga?.thumbnailUrl && (
|
||||
<div className={s.thumb}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.thumbImg}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<div
|
||||
className={s.progressBar}
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.rowRight}>
|
||||
<span className={s.stateLabel}>{item.state}</span>
|
||||
{!isActive && (
|
||||
<button
|
||||
className={s.removeBtn}
|
||||
onClick={() => dequeue(item.chapter.id)}
|
||||
disabled={isRemoving}
|
||||
title="Remove from queue"
|
||||
>
|
||||
{isRemoving
|
||||
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="light" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||
.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);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
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);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* Source picker */
|
||||
.sourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sourcePickerLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--t-base);
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) 0 var(--sp-6);
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
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;
|
||||
}
|
||||
|
||||
.sectionTitleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.seeAll {
|
||||
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);
|
||||
}
|
||||
|
||||
.seeAll:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||
.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 (shared by all rows) ───────────────────────────────────────────── */
|
||||
.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); }
|
||||
|
||||
.coverWrap {
|
||||
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;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
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);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
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 — invisible placeholder to fill row trailing space */
|
||||
.ghostCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||
|
||||
.coverSkeleton {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 11px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||
.drillRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.drillHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.drillTitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.drillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.drillCard {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||
.drillCard:hover .title { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.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;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||
.noSource {
|
||||
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);
|
||||
}
|
||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||
.exploreMoreCard {
|
||||
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;
|
||||
}
|
||||
.exploreMoreCard:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||
|
||||
.exploreMoreInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exploreMoreIcon {
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.exploreMoreLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exploreMoreGenre {
|
||||
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);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import GenreDrillPage from "./GenreDrillPage";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
const ROW_CAP = 25;
|
||||
|
||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget;
|
||||
const canScrollLeft = el.scrollLeft > 0;
|
||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
if (!canScrollLeft && !canScrollRight) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MiniCard = memo(function MiniCard({
|
||||
manga, onClick, onContextMenu, subtitle, progress,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
subtitle?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||
|
||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||
genre, onClick,
|
||||
}: { genre: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||
<div className={s.exploreMoreInner}>
|
||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title, icon, onSeeAll, loading, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||
loading?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
const genreFilter = useStore((s) => s.genreFilter);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
if (genreFilter) return <GenreDrillPage />;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||
{mode === "sources" && <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
|
||||
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Fast genre row query against the local DB
|
||||
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 status
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function ExploreFeed() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
const history = useStore((s) => s.history);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||
const folders = useStore((s) => s.settings.folders);
|
||||
const addFolder = useStore((s) => s.addFolder);
|
||||
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Data load ─────────────────────────────────────────────────────────────
|
||||
// Library + genre rows: single local DB query each — instant, no source calls.
|
||||
// Popular: still needs fetchSourceManga since there's no local equivalent.
|
||||
useEffect(() => {
|
||||
const alreadyLoaded = allManga.length > 0;
|
||||
if (alreadyLoaded) return;
|
||||
|
||||
setLoadingLib(true);
|
||||
setLoadingPopular(true);
|
||||
setLoadError(false);
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
if (retryCount > 0) {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.SOURCES);
|
||||
fetchedGenresRef.current = "";
|
||||
}
|
||||
|
||||
// Single query for all manga — library flag included
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
|
||||
.then((d) => d.mangas.nodes)
|
||||
).then(setAllManga)
|
||||
.catch((e) => { console.error(e); setLoadError(true); })
|
||||
.finally(() => setLoadingLib(false));
|
||||
|
||||
// Sources — only needed for Popular section
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then((allSources) => {
|
||||
if (allSources.length === 0) { setLoadingPopular(false); return; }
|
||||
const topSources = getTopSources(allSources).slice(0, 2);
|
||||
setSources(allSources);
|
||||
|
||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||
Promise.allSettled(
|
||||
topSources.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(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const entry of history) {
|
||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||
mangaReadAt.set(entry.mangaId, entry.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 genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 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);
|
||||
}, [allManga, history]);
|
||||
|
||||
// ── Genre rows: query local DB directly ─────────────────────────────────
|
||||
// One query per genre against the local mangas table — instant, no source I/O.
|
||||
useEffect(() => {
|
||||
if (frecencyGenres.length === 0 || allManga.length === 0) return;
|
||||
|
||||
const genreKey = frecencyGenres.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return;
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
|
||||
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;
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
}, [frecencyGenres, allManga]);
|
||||
|
||||
function openManga(m: Manga) { setPreviewManga(m); }
|
||||
|
||||
// ── Continue reading ──────────────────────────────────────────────────────
|
||||
const continueReading = useMemo(() => {
|
||||
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 entry of history) {
|
||||
if (seen.has(entry.mangaId)) continue;
|
||||
seen.add(entry.mangaId);
|
||||
const manga = mangaMap.get(entry.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
}, [history, allManga]);
|
||||
|
||||
// ── Recommended ───────────────────────────────────────────────────────────
|
||||
const recommended = useMemo(() => {
|
||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||
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);
|
||||
}, [allManga, frecencyGenres, continueReading]);
|
||||
|
||||
const genresLoading = loadingGenres;
|
||||
|
||||
return (
|
||||
<div className={s.body}>
|
||||
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{items.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{items.length >= ROW_CAP && (
|
||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||
)}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
{loadError ? (
|
||||
<>
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Grid fills entire remaining height, no show-more needed */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
/* Smooth GPU-accelerated scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
/* Solid bg shown while image fades in — matches skeleton color */
|
||||
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;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
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);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.cardSkeleton { padding: 0; }
|
||||
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Show more — spans full grid width */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./GenreDrillPage.module.css";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
const INITIAL_PAGES = 3;
|
||||
const MAX_SOURCES = 12;
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
|
||||
* multi-tag string ("Action+Romance"). Parse it into an array.
|
||||
*
|
||||
* Callers set multi-tag filters via:
|
||||
* setGenreFilter("Action+Romance")
|
||||
*
|
||||
* The Explore feed's "See all" button continues to pass single strings and
|
||||
* requires no change.
|
||||
*/
|
||||
function parseTags(genreFilter: string): string[] {
|
||||
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
|
||||
function tagsLabel(tags: string[]): string {
|
||||
if (tags.length === 1) return tags[0];
|
||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side AND filter.
|
||||
* Sources only accept a single query string, so we send the first tag and
|
||||
* drop results that don't also have the remaining tags in their genre list.
|
||||
*/
|
||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||
return tags.every((t) => genres.includes(t.toLowerCase()));
|
||||
}
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
if (signal.aborted) return;
|
||||
const item = items[i++];
|
||||
await fn(item).catch(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||
export default function GenreDrillPage() {
|
||||
const genreFilter = useStore((st) => st.genreFilter);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const settings = useStore((st) => st.settings);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
|
||||
// Parse the filter string into individual tags
|
||||
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
|
||||
// First tag is sent as the source query string (sources accept only one term)
|
||||
const primaryTag = tags[0] ?? "";
|
||||
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
// Per-source next-page tracker; -1 means exhausted
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (tags.length === 0) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setLoadingInitial(true);
|
||||
setSourceManga([]);
|
||||
setLibraryManga([]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// ── Library (local DB, instant) ───────────────────────────────────────
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
)
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||
|
||||
// ── Sources: stream results as each source responds ───────────────────
|
||||
// Source list is stable within a session — cache indefinitely.
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const sources = allSources.slice(0, MAX_SOURCES);
|
||||
sourcesRef.current = sources;
|
||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||
|
||||
await runConcurrent(sources, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
|
||||
// On navigation-away → back the pages are still in the TTL store, so fetchPage
|
||||
// returns the cached promise immediately without hitting the network.
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageItems: Manga[] = [];
|
||||
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
const result = await cache
|
||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
)
|
||||
.catch((e: any) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
|
||||
ps.add(page);
|
||||
|
||||
// For multi-tag searches: client-side AND filter for tags beyond the first.
|
||||
// Sources only support a single query string, so we send primaryTag and
|
||||
// drop results that don't contain the remaining tags in their genre array.
|
||||
const matching = tags.length > 1
|
||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||
: result.mangas;
|
||||
|
||||
pageItems.push(...matching);
|
||||
|
||||
if (!result.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
} else if (page === INITIAL_PAGES) {
|
||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
}).catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
});
|
||||
|
||||
return () => { ctrl.abort(); };
|
||||
// genreFilter (not tags) as the dep — tags is derived from it and would
|
||||
// cause an extra render on every parse; genreFilter is the stable identity.
|
||||
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Derived merged list ───────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
// For multi-tag: library results must match ALL tags
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
|
||||
return dedupeMangaById([...libMatches, ...srcOnly]);
|
||||
}, [libraryManga, sourceManga, tags]);
|
||||
|
||||
// ── Load more ─────────────────────────────────────────────────────────────
|
||||
const hasMoreVisible = visibleCount < filtered.length;
|
||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
|
||||
// Fast path: buffered results already in memory
|
||||
if (hasMoreVisible) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: fetch next pages from sources
|
||||
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
if (!sources.length) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sources, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
|
||||
const result = await cache
|
||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
)
|
||||
.catch((e: any) => {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
|
||||
ps.add(page);
|
||||
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
|
||||
const matching = tags.length > 1
|
||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||
: result.mangas;
|
||||
|
||||
if (matching.length > 0)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const visibleItems = filtered.slice(0, visibleCount);
|
||||
const label = tagsLabel(tags);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{label}</span>
|
||||
{loadingInitial && filtered.length === 0 ? null : (
|
||||
<span className={s.resultCount}>
|
||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||
</span>
|
||||
)}
|
||||
{!loadingInitial && hasMoreNetwork && (
|
||||
<span className={s.loadingHint}>More loading…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingInitial && filtered.length === 0 ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No manga found for "{label}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{visibleItems.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading…</>
|
||||
: "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
/* ── Animations ──────────────────────────────────────────────────────────── */
|
||||
@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.8 } }
|
||||
|
||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||
.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 shell ─────────────────────────────────────────────────────────── */
|
||||
.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), 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── Cover column ────────────────────────────────────────────────────────── */
|
||||
.coverCol {
|
||||
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-y: auto; overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.coverCol::-webkit-scrollbar { display: none; }
|
||||
|
||||
.coverWrap {
|
||||
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;
|
||||
}
|
||||
|
||||
.coverSpinner {
|
||||
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);
|
||||
}
|
||||
|
||||
.coverActions {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
||||
.actionBtn {
|
||||
display: flex; align-items: center; justify-content: 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;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.actionBtnActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
|
||||
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
|
||||
.actionBtnLabel {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||
.folderWrap { position: relative; width: 100%; }
|
||||
|
||||
.folderMenu {
|
||||
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;
|
||||
}
|
||||
|
||||
.folderEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.folderItem {
|
||||
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);
|
||||
}
|
||||
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.folderItemOn { color: var(--accent-fg); }
|
||||
|
||||
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
|
||||
.folderCreateRow {
|
||||
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
||||
}
|
||||
.folderInput {
|
||||
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;
|
||||
}
|
||||
.folderInput:focus { border-color: var(--border-focus); }
|
||||
|
||||
.folderOkBtn {
|
||||
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);
|
||||
}
|
||||
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.folderNewBtn {
|
||||
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);
|
||||
}
|
||||
.folderNewBtn:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Content column ──────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.contentHeader {
|
||||
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;
|
||||
}
|
||||
|
||||
.titleBlock {
|
||||
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);
|
||||
}
|
||||
|
||||
.skByline {
|
||||
height: 14px; width: 55%;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); border: none; background: none;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.contentBody {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ── Error banner ────────────────────────────────────────────────────────── */
|
||||
.errorBanner {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-warn, #f59e0b);
|
||||
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
||||
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
||||
.skRow {
|
||||
display: flex; gap: var(--sp-2); align-items: center;
|
||||
}
|
||||
.skBadge {
|
||||
height: 20px; width: 54px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.skDesc {
|
||||
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
||||
}
|
||||
.skLine {
|
||||
height: 13px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||
.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);
|
||||
}
|
||||
.badgeGreen {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
||||
color: #22c55e;
|
||||
}
|
||||
.badgeDim { /* default */ }
|
||||
.badgeAccent {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
||||
}
|
||||
.badgeUnread {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.badgeNsfw {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Chapter box — clearly separated from description ────────────────────── */
|
||||
.chapterBox {
|
||||
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);
|
||||
}
|
||||
|
||||
.chapterLoading {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
}
|
||||
.chapterLoadingLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.chapterMeta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.chapterLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.dlAllBtn {
|
||||
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);
|
||||
}
|
||||
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.progressTrack {
|
||||
height: 3px; background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full); overflow: hidden;
|
||||
}
|
||||
.progressFill {
|
||||
height: 100%; background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.readBtn {
|
||||
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);
|
||||
}
|
||||
.readBtn:hover { filter: brightness(1.1); }
|
||||
|
||||
/* ── Description block ───────────────────────────────────────────────────── */
|
||||
.descBlock {
|
||||
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;
|
||||
}
|
||||
.descOpen {
|
||||
display: block; -webkit-line-clamp: unset; overflow: visible;
|
||||
}
|
||||
|
||||
.descToggle {
|
||||
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);
|
||||
}
|
||||
.descToggle:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
|
||||
.genreTag {
|
||||
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);
|
||||
}
|
||||
|
||||
.genreTagClickable {
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genreTagClickable:hover {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||
.metaTable {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
||||
}
|
||||
.metaKey {
|
||||
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;
|
||||
}
|
||||
.metaVal {
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.metaLink {
|
||||
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);
|
||||
}
|
||||
.metaLink:hover { opacity: 0.75; }
|
||||
@@ -1,569 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
X, BookmarkSimple, ArrowSquareOut, Play,
|
||||
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import s from "./MangaPreview.module.css";
|
||||
|
||||
export default function MangaPreview() {
|
||||
const previewManga = useStore((st) => st.previewManga);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||
const setNavPage = useStore((st) => st.setNavPage);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const openReader = useStore((st) => st.openReader);
|
||||
const addToast = useStore((st) => st.addToast);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [loadingChapters, setLoadingChapters] = useState(false);
|
||||
const [togglingLib, setTogglingLib] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [queueingAll, setQueueingAll] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const detailAbort = useRef<AbortController | null>(null);
|
||||
const chapterAbort = useRef<AbortController | null>(null);
|
||||
const folderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
setPreviewManga(null);
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFolderOpen(false);
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setFetchError(null);
|
||||
}, [setPreviewManga]);
|
||||
|
||||
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
|
||||
// Abort any in-flight requests from previous manga
|
||||
detailAbort.current?.abort();
|
||||
chapterAbort.current?.abort();
|
||||
|
||||
const dCtrl = new AbortController();
|
||||
const cCtrl = new AbortController();
|
||||
detailAbort.current = dCtrl;
|
||||
chapterAbort.current = cCtrl;
|
||||
|
||||
setManga(null);
|
||||
setChapters([]);
|
||||
setDescExpanded(false);
|
||||
setFetchError(null);
|
||||
setLoadingDetail(true);
|
||||
setLoadingChapters(true);
|
||||
|
||||
const id = previewManga.id;
|
||||
|
||||
// ── Detail fetch strategy ─────────────────────────────────────────────
|
||||
// For source/explore manga we must call FETCH_MANGA (mutation that
|
||||
// hits the source and syncs to the local DB). GET_MANGA only works for
|
||||
// manga already in the local DB with full metadata.
|
||||
//
|
||||
// Fast path: if we already cached a full record, use it directly.
|
||||
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
||||
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
||||
//
|
||||
(async (): Promise<Manga> => {
|
||||
const cacheKey = CACHE_KEYS.MANGA(id);
|
||||
|
||||
// Already have a cached rich record — no network needed
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey, () =>
|
||||
Promise.resolve(previewManga as Manga)
|
||||
) as Promise<Manga>;
|
||||
}
|
||||
|
||||
// Try FETCH_MANGA first — works for all manga regardless of whether
|
||||
// they are in the local DB yet (it fetches from source and syncs).
|
||||
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;
|
||||
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
||||
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;
|
||||
// Cache the rich record so re-opening is instant
|
||||
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||
}
|
||||
setManga(fullManga);
|
||||
setLoadingDetail(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
console.error("MangaPreview detail fetch:", e);
|
||||
// Show whatever sparse data we have from previewManga
|
||||
setManga(previewManga as Manga);
|
||||
setFetchError("Could not load full details — showing cached data");
|
||||
setLoadingDetail(false);
|
||||
});
|
||||
|
||||
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
||||
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 no local chapters yet (explore/source manga), fetch from source
|
||||
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;
|
||||
// Leave nodes empty — not a fatal error
|
||||
}
|
||||
}
|
||||
if (!cCtrl.signal.aborted) setChapters(nodes);
|
||||
})
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
||||
|
||||
return () => { dCtrl.abort(); cCtrl.abort(); };
|
||||
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Keyboard close ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!previewManga) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [previewManga, close]);
|
||||
|
||||
// ── Folder outside click ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
||||
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [folderOpen]);
|
||||
|
||||
if (!previewManga) return null;
|
||||
|
||||
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
||||
const displayManga = manga ?? previewManga;
|
||||
const totalCount = chapters.length;
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const unreadCount = totalCount - readCount;
|
||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
||||
|
||||
// Scanlators — deduplicated, non-empty
|
||||
const scanlators = [...new Set(
|
||||
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
||||
)];
|
||||
|
||||
// Publication date range from chapter upload dates
|
||||
const uploadDates = chapters
|
||||
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
||||
.filter((d): d is number => d !== null && !isNaN(d));
|
||||
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const statusLabel = displayManga.status
|
||||
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||
: null;
|
||||
|
||||
const continueChapter = (() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters];
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||
return { ch: asc[0], label: "Read again" };
|
||||
})();
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
setTogglingLib(true);
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
const updated = { ...manga, inLibrary: next };
|
||||
setManga(updated);
|
||||
// Update cache so subsequent opens reflect new state
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
setTogglingLib(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;
|
||||
setQueueingAll(true);
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
setQueueingAll(false);
|
||||
}
|
||||
|
||||
function openSeriesDetail() {
|
||||
setActiveManga(displayManga);
|
||||
setNavPage("library");
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !previewManga) return;
|
||||
const newId = addFolder(name);
|
||||
assignMangaToFolder(newId, previewManga.id);
|
||||
setNewFolderName("");
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
|
||||
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.backdrop}
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||
>
|
||||
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||
|
||||
{/* ── Cover column ── */}
|
||||
<div className={s.coverCol}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||
alt={displayManga.title}
|
||||
className={s.cover}
|
||||
/>
|
||||
{loadingDetail && (
|
||||
<div className={s.coverSpinner}>
|
||||
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.coverActions}>
|
||||
<button
|
||||
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||
onClick={toggleLibrary}
|
||||
disabled={togglingLib || loadingDetail}
|
||||
>
|
||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
|
||||
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||
<Books size={13} weight="light" />
|
||||
Series Detail
|
||||
</button>
|
||||
|
||||
{/* Folder picker */}
|
||||
<div className={s.folderWrap} ref={folderRef}>
|
||||
<button
|
||||
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||
onClick={() => setFolderOpen((p) => !p)}
|
||||
>
|
||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||
<span className={s.actionBtnLabel}>
|
||||
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{folderOpen && (
|
||||
<div className={s.folderMenu}>
|
||||
{folders.length === 0 && !creatingFolder && (
|
||||
<p className={s.folderEmpty}>No folders yet</p>
|
||||
)}
|
||||
{folders.map((f) => {
|
||||
const isIn = f.mangaIds.includes(previewManga.id);
|
||||
return (
|
||||
<button key={f.id}
|
||||
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||
onClick={() => isIn
|
||||
? removeMangaFromFolder(f.id, previewManga.id)
|
||||
: assignMangaToFolder(f.id, previewManga.id)}
|
||||
>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||
{isIn ? "✓ " : ""}{f.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={s.folderDivider} />
|
||||
{creatingFolder ? (
|
||||
<div className={s.folderCreateRow}>
|
||||
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleFolderCreate();
|
||||
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||
}}
|
||||
/>
|
||||
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content column ── */}
|
||||
<div className={s.content}>
|
||||
|
||||
{/* Header — title visible immediately from previewManga */}
|
||||
<div className={s.contentHeader}>
|
||||
<div className={s.titleBlock}>
|
||||
<h2 className={s.title}>{displayManga.title}</h2>
|
||||
{loadingDetail
|
||||
? <div className={s.skByline} />
|
||||
: (displayManga.author || displayManga.artist)
|
||||
? <p className={s.byline}>
|
||||
{[displayManga.author, displayManga.artist]
|
||||
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className={s.contentBody}>
|
||||
|
||||
{/* Error banner */}
|
||||
{fetchError && (
|
||||
<div className={s.errorBanner}>{fetchError}</div>
|
||||
)}
|
||||
|
||||
{/* ── Badges ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skRow}>
|
||||
<div className={s.skBadge} />
|
||||
<div className={s.skBadge} style={{ width: 72 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.badges}>
|
||||
{statusLabel && (
|
||||
<span className={[s.badge,
|
||||
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||
].join(" ")}>{statusLabel}</span>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||
</span>
|
||||
)}
|
||||
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||
{!loadingChapters && unreadCount > 0 && (
|
||||
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chapter section — visually separated box ── */}
|
||||
<div className={s.chapterBox}>
|
||||
{loadingChapters ? (
|
||||
<div className={s.chapterLoading}>
|
||||
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||
</div>
|
||||
) : totalCount > 0 ? (
|
||||
<>
|
||||
<div className={s.chapterMeta}>
|
||||
<span className={s.chapterLabel}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||
{queueingAll ? "Queuing…" : "Download unread"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{readCount > 0 && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{continueChapter && (
|
||||
<button className={s.readBtn}
|
||||
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||
>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !loadingDetail ? (
|
||||
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||
No chapters in local library
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Description — clearly separated from chapter block ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skDesc}>
|
||||
<div className={s.skLine} style={{ width: "100%" }} />
|
||||
<div className={s.skLine} style={{ width: "88%" }} />
|
||||
<div className={s.skLine} style={{ width: "70%" }} />
|
||||
</div>
|
||||
) : displayManga.description ? (
|
||||
<div className={s.descBlock}>
|
||||
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||
{displayManga.description}
|
||||
</p>
|
||||
{displayManga.description.length > 220 && (
|
||||
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<CaretDown size={10} weight="light" style={{
|
||||
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Genre tags ── */}
|
||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{displayManga.genre.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||
title={`Browse "${g}"`}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metadata table ── */}
|
||||
{!loadingDetail && (
|
||||
<div className={s.metaTable}>
|
||||
{displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Author</span>
|
||||
<span className={s.metaVal}>{displayManga.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Artist</span>
|
||||
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Status</span>
|
||||
<span className={s.metaVal}>{statusLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Source</span>
|
||||
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && scanlators.length > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && firstUpload && lastUpload && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Published</span>
|
||||
<span className={s.metaVal}>
|
||||
{firstUpload.getTime() === lastUpload.getTime()
|
||||
? formatDate(firstUpload)
|
||||
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && downloadedCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Downloaded</span>
|
||||
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Bookmarks</span>
|
||||
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.realUrl && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Link</span>
|
||||
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||
Open <ArrowSquareOut size={11} weight="light" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
.headerActions { display: flex; gap: var(--sp-1); }
|
||||
.iconBtn {
|
||||
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);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.4; }
|
||||
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
|
||||
|
||||
.externalPanel {
|
||||
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;
|
||||
}
|
||||
.externalHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.externalTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.externalRow {
|
||||
display: flex; gap: var(--sp-2);
|
||||
}
|
||||
.externalInput {
|
||||
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);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.externalError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.installBtn {
|
||||
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;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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); }
|
||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { 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);
|
||||
}
|
||||
|
||||
.langTag {
|
||||
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);
|
||||
}
|
||||
.nsfwTag {
|
||||
background: transparent; border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.updateBadge {
|
||||
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;
|
||||
}
|
||||
.updateBadgeSmall {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--accent-fg); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.actionBtn {
|
||||
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);
|
||||
}
|
||||
.actionBtn:hover { filter: brightness(1.1); }
|
||||
.actionBtnDim {
|
||||
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);
|
||||
}
|
||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.expandBtn {
|
||||
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);
|
||||
}
|
||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expandCount {
|
||||
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;
|
||||
}
|
||||
.variantRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.variantRow:hover { background: var(--bg-raised); }
|
||||
.variantName {
|
||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.variantVersion {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.variantActions { 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);
|
||||
}
|
||||
|
||||
/* ── Panel shared styles ── */
|
||||
.externalPanel {
|
||||
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;
|
||||
}
|
||||
.panelHeader {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.panelTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.panelError {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.externalRow { display: flex; gap: var(--sp-2); }
|
||||
.externalInput {
|
||||
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);
|
||||
}
|
||||
.externalInput:focus { border-color: var(--border-focus); }
|
||||
.externalInput:disabled { opacity: 0.5; }
|
||||
.externalInputError { border-color: var(--color-error) !important; }
|
||||
.installBtn {
|
||||
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;
|
||||
}
|
||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.installBtnSuccess {
|
||||
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
||||
border-color: var(--accent-fg); color: var(--accent-fg);
|
||||
}
|
||||
|
||||
/* ── Repo list ── */
|
||||
.repoLoading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.repoEmpty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) 2px;
|
||||
}
|
||||
.repoList {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.repoRow {
|
||||
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);
|
||||
}
|
||||
.repoUrl {
|
||||
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
|
||||
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.repoRemoveBtn {
|
||||
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);
|
||||
}
|
||||
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
.repoRemoveBtn:disabled { opacity: 0.4; }
|
||||
@@ -1,407 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
|
||||
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 { useStore } from "../../store";
|
||||
import type { Extension } from "../../lib/types";
|
||||
import s from "./ExtensionList.module.css";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
interface ExtGroup {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
variants: Extension[];
|
||||
}
|
||||
|
||||
export default function ExtensionList() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("installed");
|
||||
const [search, setSearch] = useState("");
|
||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [panel, setPanel] = useState<Panel>(null);
|
||||
|
||||
// APK install state
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [installSuccess, setInstallSuccess] = useState(false);
|
||||
|
||||
// Repo management state
|
||||
const [repos, setRepos] = useState<string[]>([]);
|
||||
const [reposLoading, setReposLoading] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||
const [repoError, setRepoError] = useState<string | null>(null);
|
||||
const [savingRepos, setSavingRepos] = useState(false);
|
||||
|
||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.extensions.nodes))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
setRefreshing(true);
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
||||
.catch(console.error)
|
||||
.finally(() => setRefreshing(false));
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
setReposLoading(true);
|
||||
try {
|
||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
||||
setRepos(d.settings.extensionRepos ?? []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setReposLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[]) {
|
||||
setSavingRepos(true);
|
||||
try {
|
||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
||||
SET_EXTENSION_REPOS, { repos: updated }
|
||||
);
|
||||
setRepos(d.setSettings.settings.extensionRepos);
|
||||
} catch (e: unknown) {
|
||||
setRepoError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingRepos(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setRepoError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (repos.includes(url)) {
|
||||
setRepoError("Repo already added");
|
||||
return;
|
||||
}
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
saveRepos([...repos, url]);
|
||||
}
|
||||
|
||||
function removeRepo(url: string) {
|
||||
saveRepos(repos.filter((r) => r !== url));
|
||||
}
|
||||
|
||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
||||
setWorking((p) => new Set(p).add(pkgName));
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
||||
};
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
setInstallError("URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
if (!url.endsWith(".apk")) {
|
||||
setInstallError("URL must point to an .apk file");
|
||||
return;
|
||||
}
|
||||
setInstalling(true);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
setInstallSuccess(true);
|
||||
setExternalUrl("");
|
||||
await load();
|
||||
setTimeout(() => {
|
||||
setPanel(null);
|
||||
setInstallSuccess(false);
|
||||
}, 1500);
|
||||
} catch (e: unknown) {
|
||||
setInstallError(e instanceof Error ? e.message : "Install failed");
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
if (panel === p) {
|
||||
setPanel(null);
|
||||
return;
|
||||
}
|
||||
setPanel(p);
|
||||
setInstallError(null);
|
||||
setInstallSuccess(false);
|
||||
setExternalUrl("");
|
||||
setRepoError(null);
|
||||
setNewRepoUrl("");
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFromRepo().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = 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 = useMemo<ExtGroup[]>(() => {
|
||||
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);
|
||||
}
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary =
|
||||
all.find((v) => v.lang === preferredLang) ??
|
||||
all.find((v) => v.lang === "en") ??
|
||||
all[0];
|
||||
const variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
||||
return { base, primary, variants };
|
||||
});
|
||||
}, [filtered, preferredLang]);
|
||||
|
||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
n.has(base) ? n.delete(base) : n.add(base);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(ext: Extension) {
|
||||
if (working.has(ext.pkgName))
|
||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
||||
if (ext.hasUpdate) return (
|
||||
<div className={s.rowActions}>
|
||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
if (ext.isInstalled)
|
||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Extensions</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button
|
||||
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
||||
onClick={() => openPanel("apk")} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── APK install panel ── */}
|
||||
{panel === "apk" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Install from APK URL</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<div className={s.externalRow}>
|
||||
<input
|
||||
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/extension.apk"
|
||||
value={externalUrl}
|
||||
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
autoFocus
|
||||
disabled={installing}
|
||||
/>
|
||||
<button
|
||||
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
||||
onClick={installExternal}
|
||||
disabled={installing || !externalUrl.trim()}
|
||||
>
|
||||
{installing
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: installSuccess
|
||||
? <><Check size={13} weight="bold" /> Done</>
|
||||
: "Install"}
|
||||
</button>
|
||||
</div>
|
||||
{installError && <div className={s.panelError}>{installError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Repo management panel ── */}
|
||||
{panel === "repos" && (
|
||||
<div className={s.externalPanel}>
|
||||
<div className={s.panelHeader}>
|
||||
<span className={s.panelTitle}>Extension Repositories</span>
|
||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{reposLoading ? (
|
||||
<div className={s.repoLoading}>
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repos.length === 0 ? (
|
||||
<div className={s.repoEmpty}>No repos configured.</div>
|
||||
) : (
|
||||
<div className={s.repoList}>
|
||||
{repos.map((url) => (
|
||||
<div key={url} className={s.repoRow}>
|
||||
<span className={s.repoUrl}>{url}</span>
|
||||
<button
|
||||
className={s.repoRemoveBtn}
|
||||
onClick={() => removeRepo(url)}
|
||||
disabled={savingRepos}
|
||||
title="Remove repo"
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="bold" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
||||
<input
|
||||
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
||||
placeholder="https://example.com/index.min.json"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
||||
disabled={savingRepos}
|
||||
/>
|
||||
<button
|
||||
className={s.installBtn}
|
||||
onClick={addRepo}
|
||||
disabled={savingRepos || !newRepoUrl.trim()}
|
||||
>
|
||||
{savingRepos
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: "Add"}
|
||||
</button>
|
||||
</div>
|
||||
{repoError && <div className={s.panelError}>{repoError}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.controls}>
|
||||
<div className={s.tabs}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className={s.empty}>No extensions found.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ base, primary, variants }) => {
|
||||
const isExpanded = expanded.has(base);
|
||||
const hasVariants = variants.length > 0;
|
||||
return (
|
||||
<div key={base} className={s.group}>
|
||||
<div className={s.row}>
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.info}>
|
||||
<span className={s.name}>{base}</span>
|
||||
<span className={s.meta}>
|
||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
||||
{" "}v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
||||
{renderActions(primary)}
|
||||
{hasVariants && (
|
||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
||||
title={`${variants.length + 1} languages`}>
|
||||
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && hasVariants && (
|
||||
<div className={s.variants}>
|
||||
{variants.map((v) => (
|
||||
<div key={v.pkgName} className={s.variantRow}>
|
||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
||||
<span className={s.variantName}>{v.name}</span>
|
||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
/* GPU layer for main content area */
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useStore } from "../../store";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import Explore from "../explore/Explore";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
|
||||
function renderContent() {
|
||||
if (activeManga) return <SeriesDetail />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <Explore />;
|
||||
case "explore": return <Explore />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<Sidebar />
|
||||
<main className={s.main}>{renderContent()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||