Compare commits
223 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 | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead |
@@ -1,66 +0,0 @@
|
|||||||
name: Build AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version tag (e.g. 0.1.0)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
|
||||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
|
||||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
patchelf \
|
|
||||||
file
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Setup Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: pnpm tauri build --bundles appimage
|
|
||||||
env:
|
|
||||||
NO_STRIP: "true"
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
|
||||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: Build Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Linux x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libfuse2
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Linux x64)
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/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"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (macOS)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi binaries
|
||||||
|
run: |
|
||||||
|
download_suwayomi() {
|
||||||
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
||||||
|
-o "${outdir}.tar.gz"
|
||||||
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
|
mkdir -p "${outdir}"
|
||||||
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
|
}
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
||||||
|
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
||||||
|
"suwayomi-arm64"
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
||||||
|
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
||||||
|
"suwayomi-x64"
|
||||||
|
|
||||||
|
- name: Stage Suwayomi sidecars
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
stage_arch() {
|
||||||
|
local srcdir="$1"
|
||||||
|
local arch="$2"
|
||||||
|
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
|
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
|
|
||||||
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${arch}: jar=${JAR} java=${JAVA}"
|
||||||
|
|
||||||
|
cp -r "$srcdir" "$bundle_dest"
|
||||||
|
|
||||||
|
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
||||||
|
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
||||||
|
chmod +x "$sidecar"
|
||||||
|
echo "Staged sidecar: $sidecar"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Swap bundle for aarch64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Swap bundle for x86_64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
# Wait for the Windows workflow to have created the draft release
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
|
||||||
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Windows x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Windows x64)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/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
|
||||||
|
|
||||||
|
- name: Extract Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
|
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||||
|
cp -r "$INNER"/. suwayomi-extracted/
|
||||||
|
else
|
||||||
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
|
find suwayomi-extracted -type f | head -50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found"
|
||||||
|
find suwayomi-extracted -type f | head -50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Validate staging
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||||
|
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||||
|
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||||
|
echo "Staging OK"
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Delete existing draft release if present
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
|
echo "Deleted draft release and tag"
|
||||||
|
else
|
||||||
|
echo "No existing draft release found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app + create draft release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: v${{ github.event.inputs.version }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** Download `moku.flatpak`
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright [2026] [@Youwes09]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.2.0
|
pkgver=0.9.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Youwes09/Moku"
|
url="https://github.com/moku-project/Moku"
|
||||||
license=('Apache 2.0')
|
license=('Apache-2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -18,13 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"Suwayomi-Server-v2.1.1867.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"
|
|
||||||
)
|
)
|
||||||
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
sha256sums=(
|
||||||
|
'SKIP'
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
)
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -33,14 +33,7 @@ prepare() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
pnpm build
|
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 \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -49,19 +42,12 @@ build() {
|
|||||||
package() {
|
package() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Moku binary
|
|
||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
# Bundled JRE
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
# Suwayomi server jar
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
# tachidesk-server wrapper script
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
@@ -76,7 +62,7 @@ server.maxSourcesInParallel = 6
|
|||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
@@ -100,7 +86,7 @@ unset WAYLAND_DISPLAY
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
exec /usr/lib/moku/jre/bin/java \
|
exec java \
|
||||||
-Djava.awt.headless=true \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
@@ -109,17 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
|
|||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Desktop entry and icons
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -1,137 +1,167 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/rounded-logo.png" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
|
||||||
|
|
||||||
<table>
|
<div align="center">
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
[](https://github.com/moku-project/Moku/commits/main)
|
||||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
[](https://github.com/moku-project/Moku)
|
||||||
</tr>
|
[](https://discord.gg/x97hj8zR72)
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
</div>
|
||||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
|
||||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
<br/>
|
||||||
</tr>
|
|
||||||
</table>
|
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||||
|
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||||
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||||
|
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Reader
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
- **Single**, **double-page**, and **longstrip** reading modes
|
- **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.)
|
||||||
- **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
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
- **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
|
||||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- RTL / LTR reading direction toggle
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- Configurable page gaps
|
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||||
- Full keyboard navigation with rebindable keybinds
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- Auto-mark chapters as read when the last page is reached
|
- **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
|
||||||
### Library
|
|
||||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
|
||||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
|
||||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
|
||||||
- In-line search
|
|
||||||
- Context menu: open, add/remove from library
|
|
||||||
|
|
||||||
### Series Detail
|
|
||||||
- Cover, author, artist, status badge, genres, and synopsis
|
|
||||||
- Read progress bar with percentage
|
|
||||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
|
||||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
|
||||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
|
||||||
- Sort by newest or oldest first
|
|
||||||
- Jump-to-chapter input
|
|
||||||
- Bulk download menu: from current chapter, unread only, or all
|
|
||||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
|
||||||
- Collapsible source details panel with source ID, language, and source migration
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- Cross-source search running up to 3 concurrent requests
|
|
||||||
- Language filter bar (preferred language default, per-language, or all)
|
|
||||||
- Results grouped by source with skeleton loading states
|
|
||||||
|
|
||||||
### Sources & Extensions
|
|
||||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
|
||||||
- Extension manager: install, update, remove, and install from external APK URL
|
|
||||||
- Repo refresh with update count badge
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- Download queue with live progress
|
|
||||||
|
|
||||||
### History
|
|
||||||
- Reading history grouped by day with relative timestamps
|
|
||||||
- Per-entry thumbnail, chapter name, and last-read page
|
|
||||||
- Full-text search across titles and chapter names
|
|
||||||
- One-click clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
|
||||||
|
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Nix (recommended)**
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**winget:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Moku.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
|
|
||||||
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install io.github.moku_app.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install moku.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### macOS
|
||||||
|
|
||||||
```bash
|
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
nix build
|
> ```bash
|
||||||
./result/bin/moku
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
```
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/moku-project/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
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
|
## Stack
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [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 |
|
| [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 |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
|
|||||||
@@ -1,89 +1,39 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
- Moku-Share allows exporting of Manga
|
||||||
5. Investigate "egl:failed to create dri2 screen"
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
Bugs:
|
Minor Revisions:
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
-
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
- Add Back after Search & Clear on Search
|
|
||||||
- Add as Package in Nix Flake & Check Later
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
|
|
||||||
|
|
||||||
- Fix Mangafire Main Dispatcher Issue
|
Priority Bugs:
|
||||||
|
- Fix Library-Refresh System (TESTING)
|
||||||
|
|
||||||
|
- Suwayomi RESET
|
||||||
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
In-Progress:
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
- Clean up Migrate Model to be more initutive
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
Features:
|
|
||||||
- Add PDF Textbook Support
|
|
||||||
- Major revision to disable entire manga-subsection and use as
|
|
||||||
solely as a reader/document launcher.
|
|
||||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
-
|
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
- Fix Tracking Login
|
||||||
0. Expand into fully-fledged reader, with modular manga support
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
1. Anime & Novel Support
|
|
||||||
2. Tracker Support
|
- Tracking
|
||||||
3. Cloudflare Bypass Enable Support
|
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
||||||
4. macOS Support (feasible)
|
|
||||||
|
- Hide Completed from Library Settting
|
||||||
|
|
||||||
|
|
||||||
|
Notes from last time:
|
||||||
Testing:
|
- Currently working on #42, just need to mount panel and fix button in reader
|
||||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
|
||||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
|
||||||
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
|
||||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
|
||||||
7. Fix Scaling (100 = 125% and so forth)
|
|
||||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
|
||||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
|
||||||
11. Reader & UI needs download and other Notifications
|
|
||||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
|
||||||
- Add Refresh Details on Series Details.
|
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
|
||||||
- Remove Series Detail Mark Read & Unread
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# build-scripts/release.sh
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Usage:
|
|
||||||
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
|
|
||||||
#
|
|
||||||
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
|
|
||||||
|
|
||||||
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> [--aur|--flatpak]"
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
MODE="${2:-all}"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
AUR_DIR="${REPO_ROOT}/../moku-bin"
|
|
||||||
TARBALL="moku-${VERSION}-x86_64.tar.gz"
|
|
||||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
|
||||||
|
|
||||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
|
||||||
section "Pre-flight"
|
|
||||||
command -v nix &>/dev/null || die "nix not found"
|
|
||||||
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
|
||||||
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
|
|
||||||
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
|
|
||||||
fi
|
|
||||||
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}"
|
|
||||||
|
|
||||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
|
||||||
section "Building frontend"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
nix develop --command pnpm install --frozen-lockfile
|
|
||||||
nix develop --command pnpm build
|
|
||||||
success "Frontend built → dist/"
|
|
||||||
|
|
||||||
# ── Build Rust binary ──────────────────────────────────────────────────────────
|
|
||||||
section "Building Rust binary"
|
|
||||||
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
|
|
||||||
|
|
||||||
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
|
|
||||||
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
|
|
||||||
success "Binary → $BINARY"
|
|
||||||
|
|
||||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
|
||||||
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}"
|
|
||||||
|
|
||||||
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
|
||||||
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
|
|
||||||
|
|
||||||
# Clean up intermediate build artefacts — keep only moku.flatpak
|
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
|
||||||
success "moku.flatpak created"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
section "Assembling release tarball"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
STAGE="release-${VERSION}"
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
|
|
||||||
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
|
||||||
|
|
||||||
tar -czf "$TARBALL" "$STAGE/"
|
|
||||||
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
|
|
||||||
|
|
||||||
section "Patching PKGBUILD"
|
|
||||||
PKGBUILD="${AUR_DIR}/PKGBUILD"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
|
|
||||||
success "PKGBUILD patched"
|
|
||||||
|
|
||||||
# Tarball is only needed for the GitHub upload — remind user then it can go
|
|
||||||
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
|
|
||||||
|
|
||||||
section "Generating .SRCINFO (Arch container)"
|
|
||||||
# Mount only the AUR dir into a throwaway Arch container and run makepkg
|
|
||||||
podman run --rm \
|
|
||||||
--volume "${AUR_DIR}:/aur:z" \
|
|
||||||
--workdir /aur \
|
|
||||||
archlinux:latest \
|
|
||||||
bash -c "
|
|
||||||
pacman -Sy --noconfirm pacman >/dev/null 2>&1
|
|
||||||
source PKGBUILD
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
"
|
|
||||||
success ".SRCINFO generated"
|
|
||||||
|
|
||||||
section "Next steps"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
|
|
||||||
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}2. Push AUR:${RESET}"
|
|
||||||
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
|
|
||||||
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
|
|
||||||
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
|
|
||||||
echo -e " ${CYAN}git push origin master${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}3. Clean up:${RESET}"
|
|
||||||
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
success "v${VERSION} ready"
|
|
||||||
@@ -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": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771438068,
|
"lastModified": 1773857772,
|
||||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,32 +15,16 @@
|
|||||||
"type": "github"
|
"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": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769996383,
|
"lastModified": 1772408722,
|
||||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -49,53 +33,13 @@
|
|||||||
"type": "github"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771369470,
|
"lastModified": 1773821835,
|
||||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -107,11 +51,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769909678,
|
"lastModified": 1772328832,
|
||||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -124,7 +68,6 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
"crane": "crane",
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nix-appimage": "nix-appimage",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
@@ -136,11 +79,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771556776,
|
"lastModified": 1773975983,
|
||||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -148,21 +91,6 @@
|
|||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"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",
|
"root": "root",
|
||||||
|
|||||||
@@ -9,36 +9,27 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
nix-appimage = {
|
|
||||||
url = "github:ralismark/nix-appimage";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
];
|
|
||||||
|
|
||||||
perSystem =
|
perSystem = { system, lib, ... }:
|
||||||
{ system, pkgs, lib, ... }:
|
|
||||||
let
|
let
|
||||||
pkgs' = import inputs.nixpkgs {
|
version = "0.9.1";
|
||||||
|
|
||||||
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [
|
extensions = [ "rust-src" "rust-analyzer" ];
|
||||||
"rust-src"
|
|
||||||
"rust-analyzer"
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
@@ -65,31 +56,22 @@
|
|||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "tsconfig.node.json"
|
|| base == "vite.config.ts";
|
||||||
|| base == "vite.config.ts"
|
|
||||||
|| base == "postcss.config.js"
|
|
||||||
|| base == "postcss.config.cjs"
|
|
||||||
|| base == "tailwind.config.js"
|
|
||||||
|| base == "tailwind.config.ts";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
frontend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
version = "0.1.0";
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||||
nodejs_22
|
|
||||||
pnpm
|
|
||||||
pnpmConfigHook
|
|
||||||
];
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
version = "0.1.0";
|
inherit version;
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
@@ -111,10 +93,7 @@
|
|||||||
cargoLock = ./src-tauri/Cargo.lock;
|
cargoLock = ./src-tauri/Cargo.lock;
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||||
pkg-config
|
|
||||||
wrapGAppsHook3
|
|
||||||
];
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
@@ -126,6 +105,36 @@
|
|||||||
inherit cargoArtifacts;
|
inherit cargoArtifacts;
|
||||||
meta.mainProgram = "moku";
|
meta.mainProgram = "moku";
|
||||||
postInstall = ''
|
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 \
|
wrapProgram $out/bin/moku \
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
pkgs.gsettings-desktop-schemas
|
pkgs.gsettings-desktop-schemas
|
||||||
@@ -134,16 +143,184 @@
|
|||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||||
--set GDK_BACKEND wayland \
|
--set GDK_BACKEND wayland \
|
||||||
--set WEBKIT_FORCE_SANDBOX 0
|
--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
|
in
|
||||||
{
|
{
|
||||||
|
apps = {
|
||||||
|
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 = {
|
packages = {
|
||||||
inherit moku frontend;
|
inherit moku frontend;
|
||||||
default = moku;
|
default = moku;
|
||||||
appimage = nix-appimage.bundlers."${system}".default moku;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -155,29 +332,23 @@
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomi-server
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||||
|
|
||||||
if [ ! -e /usr/bin/xdg-open ]; then
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
echo ""
|
||||||
fi
|
echo "Release workflow:"
|
||||||
|
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
|
||||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
echo " git commit && git tag && git push"
|
||||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
|
||||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
echo " nix run .#flatpak -- <ver> build moku.flatpak"
|
||||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||||
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"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Moku</title>
|
<title>Moku</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
app-id: io.github.moku_project.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -33,13 +39,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -120,7 +123,6 @@ modules:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
@@ -138,8 +140,6 @@ modules:
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -171,17 +171,19 @@ modules:
|
|||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/moku-project/Moku.git
|
||||||
|
tag: v0.9.1
|
||||||
|
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -1,41 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"private": true,
|
"version": "0.5.0",
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||||
"tauri:build": "tauri build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/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",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"svelte-spa-router": "^4.0.1",
|
||||||
"react-dom": "^18.3.1",
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
"react-router-dom": "^6.26.0",
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
"zustand": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^18.3.3",
|
"svelte": "^5.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"svelte-check": "^3.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"typescript": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"vite": "^5.0.0"
|
||||||
"postcss": "^8.4.40",
|
|
||||||
"tailwindcss": "^3.4.7",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.4.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
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.moku_project.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.9.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Old release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.2.0"
|
version = "0.9.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "moku_lib"
|
name = "moku_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
@@ -17,11 +17,19 @@ tauri-build = { version = "2.0", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
nix = { version = "0.29", features = ["fs"] }
|
sysinfo = "0.32"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
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,19 +1,40 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Allow launching tachidesk-server",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
{
|
"shell:allow-kill",
|
||||||
"identifier": "shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"allow": [
|
"shell:allow-execute",
|
||||||
{
|
"core:window:allow-minimize",
|
||||||
"name": "tachidesk-server",
|
"core:window:allow-unminimize",
|
||||||
"cmd": "tachidesk-server"
|
"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::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use std::io::Write;
|
||||||
|
use sysinfo::Disks;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use tauri::Emitter;
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -16,18 +19,43 @@ pub struct StorageInfo {
|
|||||||
path: String,
|
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 {
|
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||||
if !downloads_path.trim().is_empty() {
|
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")
|
suwayomi_data_dir().join("downloads")
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
.join(".local/share")
|
|
||||||
});
|
|
||||||
base.join("Tachidesk/downloads")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -46,68 +74,501 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
0
|
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("/"))
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
};
|
};
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let frsize = vfs.fragment_size() as u64;
|
let disks = Disks::new_with_refreshed_list();
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
let disk = disks
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
.iter()
|
||||||
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
Ok(StorageInfo {
|
Ok(StorageInfo {
|
||||||
manga_bytes,
|
manga_bytes,
|
||||||
total_bytes,
|
total_bytes: disk.total_space(),
|
||||||
free_bytes,
|
free_bytes: disk.available_space(),
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the true OS-level scale factor for the main window.
|
|
||||||
/// This reads directly from the underlying winit window handle, bypassing
|
|
||||||
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
|
||||||
/// This is the only reliable way to get the correct DPR in all launch
|
|
||||||
/// environments — tauri dev, nix run, flatpak, etc.
|
|
||||||
#[tauri::command]
|
#[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)
|
window.scale_factor().unwrap_or(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
let mut guard = state.0.lock().unwrap();
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
if let Some(child) = guard.take() {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
println!("Killed tracked server child.");
|
|
||||||
}
|
}
|
||||||
let _ = std::process::Command::new("pkill")
|
|
||||||
.arg("-f")
|
#[cfg(target_os = "windows")]
|
||||||
.arg("tachidesk")
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.status();
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running { break; }
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "stable"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn seed_server_conf(data_dir: &PathBuf) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
if !conf_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||||
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||||
|
eprintln!("Could not write server.conf: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
||||||
|
"server.initialOpenInBrowserEnabled", "false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled", "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let mut out = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = text;
|
||||||
|
if !out.ends_with('\n') { out.push('\n'); }
|
||||||
|
out.push_str(&replacement);
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||||
|
.join("moku\\tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("io.github.moku_project.Moku.app/tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||||
|
base.join("moku/tachidesk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerInvocation {
|
||||||
|
bin: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
log: &mut Option<std::fs::File>,
|
||||||
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
|
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||||
|
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
|
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: path.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin_dir) = exe.parent() {
|
||||||
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
|
let p = bin_dir.join(name);
|
||||||
|
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.to_string_lossy().into_owned(),
|
||||||
|
args: vec![],
|
||||||
|
working_dir: Some(resource_dir.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| {
|
||||||
|
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec!["-jar".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]
|
#[tauri::command]
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
{
|
{
|
||||||
let guard = state.0.lock().unwrap();
|
let state = app.state::<ServerState>();
|
||||||
if guard.is_some() {
|
if state.0.lock().unwrap().is_some() {
|
||||||
println!("Server already running, skipping spawn.");
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let shell = app.shell();
|
let data_dir = suwayomi_data_dir();
|
||||||
match shell.command(&binary).spawn() {
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||||
|
|
||||||
|
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(&invocation.args)
|
||||||
|
.current_dir(&working_dir);
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
println!("Spawned server: {}", binary);
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
let mut guard = state.0.lock().unwrap();
|
|
||||||
*guard = Some(child);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to spawn {}: {}", binary, e);
|
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
Err(e.to_string())
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,16 +579,257 @@ fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
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_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
get_storage_info,
|
||||||
|
get_default_downloads_path,
|
||||||
|
check_path_exists,
|
||||||
|
create_directory,
|
||||||
|
migrate_downloads,
|
||||||
spawn_server,
|
spawn_server,
|
||||||
kill_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(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.2.0",
|
"version": "0.9.1",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": false
|
"decorations": false,
|
||||||
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -26,14 +27,22 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["appimage"],
|
"targets": ["nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico",
|
||||||
]
|
"icons/icon.png"
|
||||||
|
],
|
||||||
|
"externalBin": [],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerIcon": "icons/icon.ico",
|
||||||
|
"installMode": "currentUser"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
|
|||||||
@@ -9,5 +9,8 @@
|
|||||||
"devtools": true
|
"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,193 +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);
|
|
||||||
|
|
||||||
// expose devSplash trigger via window for settings
|
|
||||||
useEffect(() => {
|
|
||||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
|
||||||
return () => { delete (window as any).__mokuShowSplash; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
function resetIdle() {
|
|
||||||
setIdle(false);
|
|
||||||
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";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getServerUrl(): string {
|
||||||
try {
|
const url = store.settings.serverUrl;
|
||||||
const raw = localStorage.getItem("moku-store");
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
export function plainThumbUrl(path: string): string {
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
if (path.startsWith("http")) return path;
|
||||||
return `${getServerUrl()}${path}`;
|
return `${getServerUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
interface GQLResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
errors?: { message: string }[];
|
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> {
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
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(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
@@ -50,29 +39,17 @@ async function fetchWithRetry(
|
|||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// Bail immediately if already aborted before we start
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
// Check abort at the top of every iteration
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...init, signal });
|
const res = await fetchAuthenticated(url, init, signal);
|
||||||
|
|
||||||
// Check abort again — fetch can return a response even after abort in some runtimes
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Never retry aborted requests
|
if (e?.authRequired) throw e;
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
|
||||||
|
|
||||||
// Last retry — give up
|
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
|
|
||||||
// Abort-aware delay between retries
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,20 +61,15 @@ export async function gql<T>(
|
|||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(
|
||||||
method: "POST",
|
`${getServerUrl()}/api/graphql`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
body: JSON.stringify({ query, variables }),
|
signal,
|
||||||
}, signal);
|
);
|
||||||
|
|
||||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
|
||||||
return json.data;
|
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"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
preserveAspectRatio="xMidYMid meet">
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
fill="#2d7a5f" stroke="none">
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
</g>
|
||||||
-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>
|
</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,535 +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_ALL_MANGA, GET_LIBRARY, 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"];
|
|
||||||
|
|
||||||
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); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
|
|
||||||
useEffect(() => {
|
|
||||||
// If we already have data, no need to re-fetch (cache hit path)
|
|
||||||
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
|
||||||
if (alreadyLoaded) return;
|
|
||||||
|
|
||||||
setLoadingLib(true);
|
|
||||||
setLoadingPopular(true);
|
|
||||||
setLoadError(false);
|
|
||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
// Clear stale failed cache entries so we actually retry
|
|
||||||
if (retryCount > 0) {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
cache.clear(CACHE_KEYS.SOURCES);
|
|
||||||
fetchedGenresRef.current = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Library — fire immediately, independent of sources
|
|
||||||
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(setAllManga)
|
|
||||||
.catch((e) => { console.error(e); setLoadError(true); })
|
|
||||||
.finally(() => setLoadingLib(false));
|
|
||||||
|
|
||||||
// Sources — then kick off popular AND genres simultaneously
|
|
||||||
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); setLoadError(true); return; }
|
|
||||||
|
|
||||||
// Cap to 2 sources for the explore feed — halves the network calls
|
|
||||||
const topSources = getTopSources(allSources).slice(0, 2);
|
|
||||||
setSources(allSources);
|
|
||||||
|
|
||||||
// ── Popular — don't block genres ──────────────────────────────────
|
|
||||||
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));
|
|
||||||
|
|
||||||
// ── Genres — start immediately alongside popular using foundational
|
|
||||||
// genres as a starting point; personalized genres replace these once
|
|
||||||
// library loads. Results stream in as each genre resolves.
|
|
||||||
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
const genreKey = genresToFetch.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(
|
|
||||||
genresToFetch.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
Promise.allSettled(
|
|
||||||
topSources.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
|
||||||
}, ctrl.signal).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, 24);
|
|
||||||
})
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
// Stream: each genre paints immediately as it resolves
|
|
||||||
streamingMap.set(genre, mangas);
|
|
||||||
setGenreResults(new Map(streamingMap));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
|
||||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(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]);
|
|
||||||
|
|
||||||
// ── Re-fetch only when personalized genres differ from what's cached ───────
|
|
||||||
useEffect(() => {
|
|
||||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
|
||||||
|
|
||||||
const genreKey = frecencyGenres.join(",");
|
|
||||||
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
|
|
||||||
fetchedGenresRef.current = genreKey;
|
|
||||||
|
|
||||||
setLoadingGenres(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
const topSources = getTopSources(sources).slice(0, 2);
|
|
||||||
const streamingMap = new Map<string, Manga[]>();
|
|
||||||
|
|
||||||
Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
Promise.allSettled(
|
|
||||||
topSources.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
|
||||||
}, ctrl.signal).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, 24);
|
|
||||||
})
|
|
||||||
).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, sources]);
|
|
||||||
|
|
||||||
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,306 +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 } 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; // how many items to show at once
|
|
||||||
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
|
||||||
const MAX_SOURCES = 12; // max sources to query concurrently
|
|
||||||
const CONCURRENCY = 4; // parallel source fetches
|
|
||||||
|
|
||||||
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 genre = 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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!genre) 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 (fire-and-forget, doesn't block skeleton removal) ─────────
|
|
||||||
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 in as each source responds ────────────────
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
|
||||||
).then(async (allSources) => {
|
|
||||||
const sources = allSources.slice(0, MAX_SOURCES);
|
|
||||||
sourcesRef.current = sources;
|
|
||||||
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
|
||||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
|
||||||
|
|
||||||
await runConcurrent(sources, async (src) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
const pageItems: Manga[] = [];
|
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
|
||||||
ctrl.signal,
|
|
||||||
);
|
|
||||||
pageItems.push(...d.fetchSourceManga.mangas);
|
|
||||||
if (!d.fetchSourceManga.hasNextPage) {
|
|
||||||
nextPageRef.current.set(src.id, -1);
|
|
||||||
break;
|
|
||||||
} else if (page === INITIAL_PAGES) {
|
|
||||||
// Has more pages beyond what we fetched upfront — mark for "load more"
|
|
||||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
nextPageRef.current.set(src.id, -1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
|
||||||
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
|
||||||
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
|
||||||
// Drop the skeleton as soon as we have anything
|
|
||||||
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(); };
|
|
||||||
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// ── Derived merged list ────────────────────────────────────────────────────
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
|
||||||
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
|
||||||
return dedupeMangaById([...libMatches, ...srcAll]);
|
|
||||||
}, [libraryManga, sourceManga, genre]);
|
|
||||||
|
|
||||||
// ── 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;
|
|
||||||
|
|
||||||
// If there are buffered results, just reveal the next page
|
|
||||||
if (hasMoreVisible) {
|
|
||||||
setVisibleCount((v) => v + PAGE_SIZE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch next pages from network
|
|
||||||
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;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
|
||||||
ctrl.signal,
|
|
||||||
);
|
|
||||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
|
||||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
} finally {
|
|
||||||
if (!ctrl.signal.aborted) {
|
|
||||||
setVisibleCount((v) => v + PAGE_SIZE);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [loadingMore, hasMoreVisible, genre]);
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
|
|
||||||
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}>{genre}</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 "{genre}".</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||