Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7d3d76fa6d | |||
| fec0e5d3f6 | |||
| f866d4d0e9 | |||
| ac1c0520c5 | |||
| fff6bde8ad | |||
| c07fc90fc8 | |||
| 523fb40538 | |||
| fb82abaf21 | |||
| 0a4108218d | |||
| 7b61f85833 | |||
| cd2d79f80c | |||
| edf2af8618 |
@@ -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,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/Youwes09/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/Youwes09/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,166 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
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-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/Youwes09/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/Youwes09/Moku/releases/$RELEASE_ID"
|
||||||
|
# Also delete the tag so tauri-action can recreate it
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/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 }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
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.1.0
|
pkgver=0.5.0
|
||||||
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/shozikan/Moku"
|
url="https://github.com/Youwes09/Moku"
|
||||||
license=('MIT')
|
license=('Apache 2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -18,15 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/shozikan/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
"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=(
|
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||||
'0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d'
|
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||||
)
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -35,14 +33,8 @@ 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 .
|
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
|
||||||
@@ -51,19 +43,15 @@ 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 -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||||
|
|
||||||
# Suwayomi server jar
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
install -Dm644 "$srcdir/suwayomi-server.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"
|
||||||
@@ -111,17 +99,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.Youwes09.Moku.app.desktop \
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.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.Youwes09.Moku.app.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.Youwes09.Moku.app.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.Youwes09.Moku.app.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,145 @@
|
|||||||
<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/Youwes09/Moku/releases/latest)
|
||||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
[](./LICENSE)
|
||||||
</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="49%" alt="Home" />
|
||||||
|
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
||||||
|
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||||
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots">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
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- Auto-mark chapters as read when the last page is reached
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
### Library
|
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||||
- 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)**
|
### Flatpak (Linux, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install moku.flatpak
|
||||||
|
flatpak run dev.moku.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:Youwes09/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:Youwes09/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### Windows
|
||||||
|
|
||||||
```bash
|
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
### macOS
|
||||||
nix build
|
|
||||||
./result/bin/moku
|
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
```
|
|
||||||
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
|
> ```bash
|
||||||
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## 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/Youwes09/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,22 +1,39 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
1. Check all Keybind Toggles
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
2. Update ReadME with Comprehensive Feature List
|
- Moku-Share allows exporting of Manga
|
||||||
3. Explore Manga Upscaler
|
- Compressed Format (Storage)
|
||||||
4. Add Zoom-Slider for Zoom in Manga Reader
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
|
Minor Revisions:
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Priority Bugs:
|
||||||
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
|
- Fix Library-Refresh System (TESTING)
|
||||||
3. Patch Chapters to Grid View
|
|
||||||
5. Fix Keybind Toggles
|
|
||||||
|
|
||||||
Features:
|
- Suwayomi RESET
|
||||||
1. Frecency based Manga Suggestions
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
2. Proper Explore Tab
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
In-Progress:
|
||||||
1. Anime & Novel Support
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
Test:
|
- QOL Animations & Revamps
|
||||||
1. URL & Extension Additions
|
- Tracking Revamp
|
||||||
|
- Completely Revamp Tracking
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Notes from last time:
|
||||||
|
- Currently working on #42, just need to mount panel and fix button in reader
|
||||||
@@ -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: 7.5 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 940 KiB |
@@ -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.0";
|
||||||
|
|
||||||
|
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,14 +93,10 @@
|
|||||||
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
|
||||||
'';
|
'';
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
@@ -127,22 +105,188 @@
|
|||||||
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
|
||||||
pkgs.gtk3
|
pkgs.gtk3
|
||||||
]}" \
|
]}" \
|
||||||
--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 WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bumpScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
echo "Bumped to $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpakScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused coreutils git
|
||||||
|
nodejs_22 pnpm
|
||||||
|
appstream flatpak-builder flatpak
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
||||||
|
|
||||||
|
echo "── Bumping versions ──"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building frontend ──"
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
|
echo "── Patching manifest sha256 ──"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building flatpak ──"
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$MANIFEST"
|
||||||
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
echo "moku.flatpak created"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done — v$VERSION"
|
||||||
|
echo " -> $REPO/moku.flatpak"
|
||||||
|
echo ""
|
||||||
|
echo "After pushing the tag, run:"
|
||||||
|
echo " nix run .#pkgbuild-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
pkgbuildBumpScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-pkgbuild-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
echo "Fetching tarball sha256..."
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||||
|
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||||
|
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
||||||
|
|
||||||
|
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
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"; };
|
||||||
|
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 {
|
||||||
@@ -154,30 +298,21 @@
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomi-server
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
|
||||||
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:"
|
||||||
|
echo " nix run .#bump -- <ver> bump versions only"
|
||||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
|
||||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
|
||||||
chmod +x "$LINUXDEPLOY"
|
|
||||||
echo "linuxdeploy wrapped with appimage-run"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Moku dev shell"
|
|
||||||
echo " pnpm install && pnpm tauri:dev"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.Youwes09.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.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.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.Youwes09.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.Youwes09.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.Youwes09.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/Youwes09/Moku.git
|
||||||
|
tag: v0.8.0
|
||||||
|
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
||||||
- 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.Youwes09.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.Youwes09.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.Youwes09.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/Youwes09/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.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest 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.0"
|
||||||
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,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;
|
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,51 +74,768 @@ 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())?;
|
|
||||||
|
|
||||||
// f_frsize is the fundamental block size used for block counts.
|
let disks = Disks::new_with_refreshed_list();
|
||||||
// f_bsize (block_size()) is just the preferred I/O size and must not be
|
let disk = disks
|
||||||
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
|
.iter()
|
||||||
let frsize = vfs.fragment_size() as u64;
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
.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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_default_downloads_path() -> String {
|
||||||
|
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn check_path_exists(path: String) -> bool {
|
||||||
|
std::path::Path::new(path.trim()).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let src_path = std::path::PathBuf::from(src.trim());
|
||||||
|
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||||
|
|
||||||
|
if !src_path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: u64 = WalkDir::new(&src_path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_file())
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||||
|
|
||||||
|
let mut done: u64 = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
let target = dst_path.join(rel);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||||
|
done += 1;
|
||||||
|
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||||
|
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||||
|
window.scale_factor().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if let Some(child) = state.0.lock().unwrap().take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
let still_running = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !still_running { break; }
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Youwes09.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]
|
||||||
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||||
|
{
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
if state.0.lock().unwrap().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
let log_path = data_dir.join("moku-spawn.log");
|
||||||
|
let _ = std::fs::create_dir_all(&data_dir);
|
||||||
|
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||||
|
|
||||||
|
do_log(&mut log, &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)) => {
|
||||||
|
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||||
|
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
kill_tachidesk(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.github.com/repos/Youwes09/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/Youwes09/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![get_storage_info])
|
.invoke_handler(tauri::generate_handler![
|
||||||
.setup(|app| {
|
get_storage_info,
|
||||||
let shell = app.shell();
|
get_default_downloads_path,
|
||||||
let app_handle = app.handle().clone();
|
check_path_exists,
|
||||||
|
create_directory,
|
||||||
let status = shell.command("tachidesk-server").spawn();
|
migrate_downloads,
|
||||||
|
spawn_server,
|
||||||
match status {
|
kill_server,
|
||||||
Ok((_rx, child)) => {
|
get_platform_ui_scale,
|
||||||
println!("Tachidesk server process spawned successfully.");
|
list_releases,
|
||||||
let state = app_handle.state::<ServerState>();
|
download_and_install_update,
|
||||||
let mut guard = state.0.lock().unwrap();
|
restart_app,
|
||||||
*guard = Some(child);
|
open_path,
|
||||||
|
pick_downloads_folder,
|
||||||
|
export_app_data,
|
||||||
|
import_app_data,
|
||||||
|
auto_backup_app_data,
|
||||||
|
get_auto_backup_dir,
|
||||||
|
])
|
||||||
|
.setup(|_app| Ok(()))
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let WindowEvent::Destroyed = event {
|
||||||
|
kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to spawn Tachidesk server: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku");
|
||||||
|
|||||||
@@ -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.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.Youwes09.Moku.app",
|
||||||
"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,18 +27,32 @@
|
|||||||
},
|
},
|
||||||
"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": {
|
||||||
"open": true
|
"open": true
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||||
|
"endpoints": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,8 @@
|
|||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"externalBin": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,20 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"resources": [
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,54 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
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 TitleBar from "./components/layout/TitleBar";
|
|
||||||
import s from "./App.module.css";
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
|
||||||
}, [settings.uiScale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevent = (e: MouseEvent) => e.preventDefault();
|
|
||||||
document.addEventListener("contextmenu", prevent);
|
|
||||||
return () => document.removeEventListener("contextmenu", prevent);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Global Tauri download-progress listener — no polling, always current
|
|
||||||
useEffect(() => {
|
|
||||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
|
||||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
|
||||||
setActiveDownloads(e.payload);
|
|
||||||
});
|
|
||||||
return () => { unsub.then((fn) => fn()); };
|
|
||||||
}, [setActiveDownloads]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
{!activeChapter && <TitleBar />}
|
|
||||||
<div className={s.content}>
|
|
||||||
{activeChapter ? <Reader /> : <Layout />}
|
|
||||||
</div>
|
|
||||||
{settingsOpen && <Settings />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { fetchAuthenticated } from "../core/auth";
|
||||||
|
|
||||||
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
|
function getServerUrl(): string {
|
||||||
|
const url = store.settings.serverUrl;
|
||||||
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plainThumbUrl(path: string): string {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
return `${getServerUrl()}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
|
interface GQLResponse<T> {
|
||||||
|
data: T;
|
||||||
|
errors?: { message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 3,
|
||||||
|
delayMs = 300,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
try {
|
||||||
|
const res = await fetchAuthenticated(url, init, signal);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return res;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.authRequired) throw e;
|
||||||
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (i === retries - 1) throw e;
|
||||||
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gql<T>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`${getServerUrl()}/api/graphql`,
|
||||||
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export * from "./client";
|
||||||
|
export * from "./queries/manga";
|
||||||
|
export * from "./queries/chapters";
|
||||||
|
export * from "./queries/downloads";
|
||||||
|
export * from "./queries/extensions";
|
||||||
|
export * from "./queries/tracking";
|
||||||
|
export * from "./mutations/manga";
|
||||||
|
export * from "./mutations/chapters";
|
||||||
|
export * from "./mutations/downloads";
|
||||||
|
export * from "./mutations/extensions";
|
||||||
|
export * from "./mutations/tracking";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export const FETCH_CHAPTERS = `
|
||||||
|
mutation FetchChapters($mangaId: Int!) {
|
||||||
|
fetchChapters(input: { mangaId: $mangaId }) {
|
||||||
|
chapters {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_CHAPTER_PAGES = `
|
||||||
|
mutation FetchChapterPages($chapterId: Int!) {
|
||||||
|
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTER_READ = `
|
||||||
|
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||||
|
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||||
|
chapter { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_CHAPTERS_READ = `
|
||||||
|
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||||
|
chapters { id isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CHAPTERS_PROGRESS = `
|
||||||
|
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
|
||||||
|
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
|
||||||
|
chapters { id isRead isBookmarked lastPageRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||||
|
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||||
|
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||||
|
chapters { id isDownloaded }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const QUEUE_FRAGMENT = `
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_DOWNLOAD = `
|
||||||
|
mutation EnqueueDownload($chapterId: Int!) {
|
||||||
|
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_DOWNLOAD = `
|
||||||
|
mutation DequeueDownload($chapterId: Int!) {
|
||||||
|
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
dequeueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REORDER_DOWNLOAD = `
|
||||||
|
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
|
||||||
|
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const START_DOWNLOADER = `
|
||||||
|
mutation StartDownloader {
|
||||||
|
startDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const STOP_DOWNLOADER = `
|
||||||
|
mutation StopDownloader {
|
||||||
|
stopDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_DOWNLOADER = `
|
||||||
|
mutation ClearDownloader {
|
||||||
|
clearDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_SOURCE_MANGA = `
|
||||||
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
|
mangas { id title thumbnailUrl inLibrary }
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_DOWNLOADS_PATH = `
|
||||||
|
mutation SetDownloadsPath($path: String!) {
|
||||||
|
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||||
|
settings { downloadsPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_LOCAL_SOURCE_PATH = `
|
||||||
|
mutation SetLocalSourcePath($path: String!) {
|
||||||
|
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||||
|
settings { localSourcePath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const FETCH_EXTENSIONS = `
|
||||||
|
mutation FetchExtensions {
|
||||||
|
fetchExtensions(input: {}) {
|
||||||
|
extensions {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTENSION = `
|
||||||
|
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extension { apkName pkgName name isInstalled hasUpdate }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||||
|
mutation InstallExternalExtension($url: String!) {
|
||||||
|
installExternalExtension(input: { extensionUrl: $url }) {
|
||||||
|
extension { apkName pkgName name isInstalled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_EXTENSION_REPOS = `
|
||||||
|
mutation SetExtensionRepos($repos: [String!]!) {
|
||||||
|
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SERVER_AUTH = `
|
||||||
|
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||||
|
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||||
|
settings { authMode authUsername }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOCKS_PROXY = `
|
||||||
|
mutation SetSocksProxy(
|
||||||
|
$socksProxyEnabled: Boolean!
|
||||||
|
$socksProxyHost: String!
|
||||||
|
$socksProxyPort: String!
|
||||||
|
$socksProxyVersion: Int!
|
||||||
|
$socksProxyUsername: String!
|
||||||
|
$socksProxyPassword: String!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
socksProxyEnabled: $socksProxyEnabled
|
||||||
|
socksProxyHost: $socksProxyHost
|
||||||
|
socksProxyPort: $socksProxyPort
|
||||||
|
socksProxyVersion: $socksProxyVersion
|
||||||
|
socksProxyUsername: $socksProxyUsername
|
||||||
|
socksProxyPassword: $socksProxyPassword
|
||||||
|
}}) {
|
||||||
|
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_FLARESOLVERR = `
|
||||||
|
mutation SetFlareSolverr(
|
||||||
|
$flareSolverrEnabled: Boolean!
|
||||||
|
$flareSolverrUrl: String!
|
||||||
|
$flareSolverrTimeout: Int!
|
||||||
|
$flareSolverrSessionName: String!
|
||||||
|
$flareSolverrSessionTtl: Int!
|
||||||
|
$flareSolverrAsResponseFallback: Boolean!
|
||||||
|
) {
|
||||||
|
setSettings(input: { settings: {
|
||||||
|
flareSolverrEnabled: $flareSolverrEnabled
|
||||||
|
flareSolverrUrl: $flareSolverrUrl
|
||||||
|
flareSolverrTimeout: $flareSolverrTimeout
|
||||||
|
flareSolverrSessionName: $flareSolverrSessionName
|
||||||
|
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
||||||
|
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
||||||
|
}}) {
|
||||||
|
settings {
|
||||||
|
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||||
|
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export const FETCH_MANGA = `
|
||||||
|
mutation FetchManga($id: Int!) {
|
||||||
|
fetchManga(input: { id: $id }) {
|
||||||
|
manga {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
source { id name displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA = `
|
||||||
|
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||||
|
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
manga { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGAS = `
|
||||||
|
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||||
|
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||||
|
mangas { id inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_MANGA_CATEGORIES = `
|
||||||
|
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
manga { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_CATEGORY = `
|
||||||
|
mutation CreateCategory($name: String!) {
|
||||||
|
createCategory(input: { name: $name }) {
|
||||||
|
category { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY = `
|
||||||
|
mutation UpdateCategory($id: Int!, $name: String) {
|
||||||
|
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||||
|
category { id name order }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY = `
|
||||||
|
mutation DeleteCategory($id: Int!) {
|
||||||
|
deleteCategory(input: { categoryId: $id }) {
|
||||||
|
category { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CATEGORY_ORDER = `
|
||||||
|
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||||
|
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||||
|
categories { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY = `
|
||||||
|
mutation UpdateLibrary {
|
||||||
|
updateLibrary(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CREATE_BACKUP = `
|
||||||
|
mutation CreateBackup {
|
||||||
|
createBackup(input: {}) { url }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESTORE_BACKUP = `
|
||||||
|
mutation RestoreBackup($backup: Upload!) {
|
||||||
|
restoreBackup(input: { backup: $backup }) {
|
||||||
|
id
|
||||||
|
status { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
# Mutations
|
||||||
|
|
||||||
|
## Manga (`mutations/manga.ts`)
|
||||||
|
|
||||||
|
### `FETCH_MANGA`
|
||||||
|
Fetches and refreshes manga metadata from its source.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGA`
|
||||||
|
Updates a single manga's library membership.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGAS`
|
||||||
|
Bulk-updates library membership for multiple manga.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Manga IDs |
|
||||||
|
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_MANGA_CATEGORIES`
|
||||||
|
Adds or removes a manga from categories.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
| `addTo` | `[Int!]!` | Category IDs to add to |
|
||||||
|
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CREATE_CATEGORY`
|
||||||
|
Creates a new manga category.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `name` | `String!` | Category name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CATEGORY`
|
||||||
|
Updates a category's name.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
| `name` | `String` | New name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE_CATEGORY`
|
||||||
|
Deletes a category by ID.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CATEGORY_ORDER`
|
||||||
|
Moves a category to a new position.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Category ID |
|
||||||
|
| `position` | `Int!` | New position index |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_LIBRARY`
|
||||||
|
Triggers a library-wide metadata refresh and returns job status.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CREATE_BACKUP`
|
||||||
|
Creates a backup and returns its download URL.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `RESTORE_BACKUP`
|
||||||
|
Restores a backup from an uploaded file and returns restore job status.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `backup` | `Upload!` | Backup file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`mutations/chapters.ts`)
|
||||||
|
|
||||||
|
### `FETCH_CHAPTERS`
|
||||||
|
Fetches/refreshes the chapter list for a manga from its source.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_CHAPTER_PAGES`
|
||||||
|
Fetches the page URLs for a specific chapter.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `MARK_CHAPTER_READ`
|
||||||
|
Marks a single chapter as read or unread.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Chapter ID |
|
||||||
|
| `isRead` | `Boolean!` | Read state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `MARK_CHAPTERS_READ`
|
||||||
|
Bulk-marks multiple chapters as read or unread.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
| `isRead` | `Boolean!` | Read state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_CHAPTERS_PROGRESS`
|
||||||
|
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
| `isRead` | `Boolean` | Read state |
|
||||||
|
| `isBookmarked` | `Boolean` | Bookmark state |
|
||||||
|
| `lastPageRead` | `Int` | Last page index read |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE_DOWNLOADED_CHAPTERS`
|
||||||
|
Deletes downloaded chapter files for the given chapter IDs.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ids` | `[Int!]!` | Chapter IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`mutations/downloads.ts`)
|
||||||
|
|
||||||
|
### `ENQUEUE_DOWNLOAD`
|
||||||
|
Adds a single chapter to the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
||||||
|
Adds multiple chapters to the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DEQUEUE_DOWNLOAD`
|
||||||
|
Removes a chapter from the download queue.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `chapterId` | `Int!` | Chapter ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `START_DOWNLOADER`
|
||||||
|
Starts the downloader and returns the current queue state.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `STOP_DOWNLOADER`
|
||||||
|
Stops the downloader and returns the current queue state.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `CLEAR_DOWNLOADER`
|
||||||
|
Clears all items from the download queue.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_SOURCE_MANGA`
|
||||||
|
Fetches manga from a source (browse/search), with pagination and optional filters.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `source` | `LongString!` | Source ID |
|
||||||
|
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
||||||
|
| `page` | `Int!` | Page number |
|
||||||
|
| `query` | `String` | Search query |
|
||||||
|
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_DOWNLOADS_PATH`
|
||||||
|
Sets the downloads directory path in settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `path` | `String!` | Filesystem path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_LOCAL_SOURCE_PATH`
|
||||||
|
Sets the local source directory path in settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `path` | `String!` | Filesystem path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`mutations/extensions.ts`)
|
||||||
|
|
||||||
|
### `FETCH_EXTENSIONS`
|
||||||
|
Fetches the latest extension list from configured repos.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_EXTENSION`
|
||||||
|
Installs, uninstalls, or updates an extension.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `String!` | Extension package name |
|
||||||
|
| `install` | `Boolean` | Install the extension |
|
||||||
|
| `uninstall` | `Boolean` | Uninstall the extension |
|
||||||
|
| `update` | `Boolean` | Update the extension |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `INSTALL_EXTERNAL_EXTENSION`
|
||||||
|
Installs an extension from an external APK URL.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `url` | `String!` | APK download URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_EXTENSION_REPOS`
|
||||||
|
Sets the list of extension repository URLs.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `repos` | `[String!]!` | Repository URLs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_SERVER_AUTH`
|
||||||
|
Configures server authentication mode and credentials.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `authMode` | `AuthMode!` | Auth mode |
|
||||||
|
| `authUsername` | `String!` | Username |
|
||||||
|
| `authPassword` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_SOCKS_PROXY`
|
||||||
|
Configures SOCKS proxy settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
||||||
|
| `socksProxyHost` | `String!` | Proxy host |
|
||||||
|
| `socksProxyPort` | `String!` | Proxy port |
|
||||||
|
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
||||||
|
| `socksProxyUsername` | `String!` | Proxy username |
|
||||||
|
| `socksProxyPassword` | `String!` | Proxy password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SET_FLARESOLVERR`
|
||||||
|
Configures FlareSolverr integration settings.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
||||||
|
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
||||||
|
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
||||||
|
| `flareSolverrSessionName` | `String!` | Session name |
|
||||||
|
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
||||||
|
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`mutations/tracking.ts`)
|
||||||
|
|
||||||
|
### `BIND_TRACK`
|
||||||
|
Binds a manga to a remote tracker entry.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UPDATE_TRACK`
|
||||||
|
Updates tracking progress, status, score, and dates for a track record.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
| `status` | `Int` | Reading status |
|
||||||
|
| `lastChapterRead` | `Float` | Last chapter read |
|
||||||
|
| `scoreString` | `String` | Score in tracker's format |
|
||||||
|
| `startDate` | `LongString` | Start date |
|
||||||
|
| `finishDate` | `LongString` | Finish date |
|
||||||
|
| `private` | `Boolean` | Mark as private |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UNBIND_TRACK`
|
||||||
|
Unbinds a manga from a tracker record.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `FETCH_TRACK`
|
||||||
|
Refreshes a track record from the remote tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `recordId` | `Int!` | Track record ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_TRACKER_OAUTH`
|
||||||
|
Initiates OAuth login for a tracker using a callback URL.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `callbackUrl` | `String!` | OAuth callback URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_TRACKER_CREDENTIALS`
|
||||||
|
Logs into a tracker using username and password.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `username` | `String!` | Username |
|
||||||
|
| `password` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGOUT_TRACKER`
|
||||||
|
Logs out of a tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LOGIN_USER`
|
||||||
|
Authenticates a user and returns access and refresh tokens.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `username` | `String!` | Username |
|
||||||
|
| `password` | `String!` | Password |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `REFRESH_TOKEN`
|
||||||
|
Refreshes the current access token.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const TRACK_RECORD_FRAGMENT = `
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BIND_TRACK = `
|
||||||
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
|
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_TRACK = `
|
||||||
|
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
|
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UNBIND_TRACK = `
|
||||||
|
mutation UnbindTrack($recordId: Int!) {
|
||||||
|
unbindTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_TRACK = `
|
||||||
|
mutation FetchTrack($recordId: Int!) {
|
||||||
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||||
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
|
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_TRACKER = `
|
||||||
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
|
tracker { id name isLoggedIn authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_USER = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken {
|
||||||
|
refreshToken { accessToken }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const GET_RECENTLY_UPDATED = `
|
||||||
|
query GetRecentlyUpdated {
|
||||||
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
nodes {
|
||||||
|
mangaId
|
||||||
|
fetchedAt
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CHAPTERS = `
|
||||||
|
query GetChapters($mangaId: Int!) {
|
||||||
|
chapters(condition: { mangaId: $mangaId }) {
|
||||||
|
nodes {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
query GetDownloadStatus {
|
||||||
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const GET_LOCAL_MANGA = `
|
||||||
|
query GetLocalManga {
|
||||||
|
mangas(condition: { sourceId: "0" }) {
|
||||||
|
nodes { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_EXTENSIONS = `
|
||||||
|
query GetExtensions {
|
||||||
|
extensions {
|
||||||
|
nodes {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCES = `
|
||||||
|
query GetSources {
|
||||||
|
sources {
|
||||||
|
nodes { id name lang displayName iconUrl isNsfw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SETTINGS = `
|
||||||
|
query GetSettings {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SERVER_SECURITY = `
|
||||||
|
query GetServerSecurity {
|
||||||
|
settings {
|
||||||
|
authMode authUsername
|
||||||
|
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
||||||
|
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||||
|
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export const GET_LIBRARY = `
|
||||||
|
query GetLibrary {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary downloadCount unreadCount
|
||||||
|
description status author artist genre
|
||||||
|
source { id name displayName }
|
||||||
|
chapters { totalCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_MANGA = `
|
||||||
|
query GetAllManga {
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA = `
|
||||||
|
query GetManga($id: Int!) {
|
||||||
|
manga(id: $id) {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
source { id name displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id name order default includeInUpdate includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||||
|
query GetDownloadedChaptersPages {
|
||||||
|
chapters(condition: { isDownloaded: true }) {
|
||||||
|
nodes { pageCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADS_PATH = `
|
||||||
|
query GetDownloadsPath {
|
||||||
|
settings { downloadsPath localSourcePath }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
|
query LibraryUpdateStatus {
|
||||||
|
libraryUpdateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
|
||||||
|
mangaUpdates {
|
||||||
|
status
|
||||||
|
manga { id title thumbnailUrl unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_RESTORE_STATUS = `
|
||||||
|
query GetRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name }
|
||||||
|
missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MANGAS_BY_GENRE = `
|
||||||
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
|
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Queries
|
||||||
|
|
||||||
|
## Manga (`queries/manga.ts`)
|
||||||
|
|
||||||
|
### `GET_LIBRARY`
|
||||||
|
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_ALL_MANGA`
|
||||||
|
Fetches all manga (library and non-library) with minimal fields.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_MANGA`
|
||||||
|
Fetches a single manga by ID with full metadata and source info.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_CATEGORIES`
|
||||||
|
Fetches all categories with their order, settings, and the manga assigned to each.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
||||||
|
Fetches page counts for all downloaded chapters.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_DOWNLOADS_PATH`
|
||||||
|
Fetches the configured downloads path and local source path from settings.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `LIBRARY_UPDATE_STATUS`
|
||||||
|
Fetches the current library update job status, including progress and any manga with new chapters.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_RESTORE_STATUS`
|
||||||
|
Fetches the status of a backup restore operation by its job ID.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `String!` | Restore job ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VALIDATE_BACKUP`
|
||||||
|
Validates a backup file and returns any missing sources or trackers.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `backup` | `Upload!` | Backup file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`queries/chapters.ts`)
|
||||||
|
|
||||||
|
### `GET_CHAPTERS`
|
||||||
|
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`queries/downloads.ts`)
|
||||||
|
|
||||||
|
### `GET_DOWNLOAD_STATUS`
|
||||||
|
Fetches the current downloader state and full queue with chapter and manga info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`queries/extensions.ts`)
|
||||||
|
|
||||||
|
### `GET_EXTENSIONS`
|
||||||
|
Fetches all extensions with install status, update availability, and metadata.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SOURCES`
|
||||||
|
Fetches all available sources with language and NSFW flags.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SETTINGS`
|
||||||
|
Fetches extension repository settings.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_SERVER_SECURITY`
|
||||||
|
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`queries/tracking.ts`)
|
||||||
|
|
||||||
|
### `GET_TRACKERS`
|
||||||
|
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_MANGA_TRACK_RECORDS`
|
||||||
|
Fetches all tracking records for a specific manga across all trackers.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `mangaId` | `Int!` | Manga ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SEARCH_TRACKER`
|
||||||
|
Searches a tracker for manga by query string.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
|
| `query` | `String!` | Search query |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_ALL_TRACKER_RECORDS`
|
||||||
|
Fetches all trackers and their full track records, including associated manga info.
|
||||||
|
|
||||||
|
**Variables:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET_TRACKER_RECORDS`
|
||||||
|
Fetches track records for a specific tracker.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `trackerId` | `Int!` | Tracker ID |
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export const GET_TRACKERS = `
|
||||||
|
query GetTrackers {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn authUrl supportsPrivateTracking scores
|
||||||
|
statuses { value name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
|
manga(id: $mangaId) {
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SEARCH_TRACKER = `
|
||||||
|
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||||
|
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||||
|
trackSearches {
|
||||||
|
id trackerId remoteId title coverUrl summary
|
||||||
|
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_TRACKER_RECORDS = `
|
||||||
|
query GetAllTrackerRecords {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn scores
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId title status displayScore lastChapterRead
|
||||||
|
totalChapters remoteUrl private
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_TRACKER_RECORDS = `
|
||||||
|
query GetTrackerRecords($trackerId: Int!) {
|
||||||
|
trackers(condition: { id: $trackerId }) {
|
||||||
|
nodes {
|
||||||
|
id name
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id title status displayScore lastChapterRead totalChapters remoteUrl
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,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,11 +1,6 @@
|
|||||||
<?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"
|
|
||||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
|
||||||
fill="#2d7a5f" stroke="none">
|
|
||||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
<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
|
-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
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
|||||||
|
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,200 +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 { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
/* 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 { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.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,152 +0,0 @@
|
|||||||
import { useEffect, useState } 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 setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => {
|
|
||||||
setStatus(d.downloadStatus);
|
|
||||||
setActiveDownloads(
|
|
||||||
d.downloadStatus.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 1500);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
|
||||||
poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
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}>
|
|
||||||
{isRunning ? (
|
|
||||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
|
||||||
<Pause size={14} weight="fill" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
|
||||||
<Play size={14} weight="fill" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
|
||||||
<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}>{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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.chapter.id}
|
|
||||||
className={[s.row, isActive ? s.rowActive : ""].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)}
|
|
||||||
title="Remove from queue"
|
|
||||||
>
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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 "../sources/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 (navPage === "library" && 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
.root {
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-void);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--sp-4) 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: var(--sp-3);
|
|
||||||
overflow: visible;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
|
|
||||||
.logoIcon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: contain;
|
|
||||||
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-position: center;
|
|
||||||
-webkit-mask-size: contain;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
margin-top: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsBtn {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
|
||||||
}
|
|
||||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
|
||||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useStore, type NavPage } from "../../store";
|
|
||||||
import s from "./Sidebar.module.css";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
|
||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
|
||||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const navPage = useStore((state) => state.navPage);
|
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const openSettings = useStore((state) => state.openSettings);
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
setNavPage(id);
|
|
||||||
if (id !== "explore") setActiveSource(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
setNavPage("library");
|
|
||||||
setActiveSource(null);
|
|
||||||
setActiveManga(null);
|
|
||||||
setLibraryFilter("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={s.root}>
|
|
||||||
{/* Logo click → back to library root */}
|
|
||||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
|
||||||
<div className={s.logoIcon} />
|
|
||||||
</button>
|
|
||||||
<nav className={s.nav}>
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button key={tab.id} title={tab.label}
|
|
||||||
onClick={() => navigate(tab.id)}
|
|
||||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
|
||||||
{tab.icon}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className={s.bottom}>
|
|
||||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
/* Drag region covers the whole bar */
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
/* Controls must NOT be draggable */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnClose:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: #c0392b;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import s from "./TitleBar.module.css";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
export default function TitleBar() {
|
|
||||||
return (
|
|
||||||
<div className={s.bar} data-tauri-drag-region>
|
|
||||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
|
||||||
<div className={s.controls}>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.minimize()}
|
|
||||||
title="Minimize"
|
|
||||||
aria-label="Minimize"
|
|
||||||
>
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.toggleMaximize()}
|
|
||||||
title="Maximize"
|
|
||||||
aria-label="Maximize"
|
|
||||||
>
|
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1"
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.btn, s.btnClose].join(" ")}
|
|
||||||
onClick={() => win.close()}
|
|
||||||
title="Close"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +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;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.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 28px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.searchClear {
|
|
||||||
position: absolute; right: 7px;
|
|
||||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
|
||||||
background: none; border: none; cursor: pointer; padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchClear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clearBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
|
||||||
.groupLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.row:hover .playIcon { opacity: 1; }
|
|
||||||
|
|
||||||
/* Thumb with session count badge */
|
|
||||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb {
|
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover; display: block; background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.sessionBadge {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 1px 4px; border-radius: 6px;
|
|
||||||
line-height: 1.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.mangaTitle {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.chapterName {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
|
||||||
}
|
|
||||||
.chapterRange {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
color: var(--text-muted); font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.rangeSep {
|
|
||||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pageBadge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.time {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.playIcon {
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
opacity: 0; transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1; display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { useStore, type HistoryEntry } from "../../store";
|
|
||||||
import s from "./History.module.css";
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts;
|
|
||||||
const m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yesterday = new Date(now);
|
|
||||||
yesterday.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
|
||||||
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
|
||||||
// into one session card showing the chapter range read.
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
|
||||||
|
|
||||||
export interface ReadingSession {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: ReadingSession[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next);
|
|
||||||
j++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const latest = group[0];
|
|
||||||
const oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
|
||||||
const groups = new Map<string, ReadingSession[]>();
|
|
||||||
for (const sess of sessions) {
|
|
||||||
const label = dayLabel(sess.readAt);
|
|
||||||
if (!groups.has(label)) groups.set(label, []);
|
|
||||||
groups.get(label)!.push(sess);
|
|
||||||
}
|
|
||||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function History() {
|
|
||||||
const history = useStore((s) => s.history);
|
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return history;
|
|
||||||
return history.filter(
|
|
||||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}, [history, search]);
|
|
||||||
|
|
||||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
|
||||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
|
||||||
|
|
||||||
function resumeReading(session: ReadingSession) {
|
|
||||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>History</h1>
|
|
||||||
<div className={s.headerRight}>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input className={s.search} placeholder="Search history…"
|
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
{search && (
|
|
||||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{history.length > 0 && (
|
|
||||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No reading history yet</p>
|
|
||||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
) : sessions.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map(({ label, items }) => (
|
|
||||||
<div key={label} className={s.group}>
|
|
||||||
<p className={s.groupLabel}>{label}</p>
|
|
||||||
{items.map((session) => (
|
|
||||||
<button
|
|
||||||
key={`${session.latestChapterId}-${session.readAt}`}
|
|
||||||
className={s.row}
|
|
||||||
onClick={() => resumeReading(session)}
|
|
||||||
>
|
|
||||||
<div className={s.thumbWrap}>
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
|
||||||
{session.chapterCount > 1 && (
|
|
||||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
|
||||||
<span className={s.chapterName}>
|
|
||||||
{session.chapterCount > 1 ? (
|
|
||||||
<span className={s.chapterRange}>
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span className={s.rangeSep}>→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{session.latestPageNumber > 1 && (
|
|
||||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={12} weight="fill" className={s.playIcon} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
/* GPU acceleration for smooth scrolling */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter tabs */
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.tabCount {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.searchWrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 10px 5px 28px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
width: 180px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
|
||||||
.virtualRow {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: 0 var(--sp-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual card fills its flex slot */
|
|
||||||
.card {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghostCard {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
/* GPU-accelerated compositing */
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
/* Hint to compositor */
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloadedBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
right: var(--sp-1);
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-6) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton */
|
|
||||||
.cardSkeleton { padding: 0; }
|
|
||||||
|
|
||||||
.coverSkeletonWrap {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSkeleton {
|
|
||||||
height: 12px;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ghost cards fill trailing grid space without taking interaction */
|
|
||||||
.ghostCard {
|
|
||||||
padding: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 60%;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
gap: var(--sp-2);
|
|
||||||
text-align: center;
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
/* ── Tag filter ── */
|
|
||||||
.tagPanel {
|
|
||||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagChip {
|
|
||||||
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: none; color: var(--text-faint); cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.tagChipActive {
|
|
||||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.tagClear {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
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(--color-error);
|
|
||||||
background: none; color: var(--color-error); cursor: pointer;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagClear:hover { background: var(--color-error-bg); }
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import s from "./Library.module.css";
|
|
||||||
|
|
||||||
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
|
|
||||||
|
|
||||||
const MangaCard = memo(function MangaCard({
|
|
||||||
manga,
|
|
||||||
onClick,
|
|
||||||
onContextMenu,
|
|
||||||
cropCovers,
|
|
||||||
}: {
|
|
||||||
manga: Manga;
|
|
||||||
onClick: () => void;
|
|
||||||
onContextMenu: (e: React.MouseEvent) => void;
|
|
||||||
cropCovers: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.cover}
|
|
||||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{!!manga.downloadCount && (
|
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{manga.title}</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Library() {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const settings = useStore((state) => state.settings);
|
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
|
||||||
const folders = useStore((state) => state.settings.folders);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
|
||||||
.then((lib) => setAllManga(lib.mangas.nodes))
|
|
||||||
.catch((e) => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset scroll when filter/search changes
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
|
||||||
}, [libraryFilter, search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let items = allManga;
|
|
||||||
if (libraryFilter === "library") {
|
|
||||||
items = items.filter((m) => m.inLibrary);
|
|
||||||
} else if (libraryFilter === "downloaded") {
|
|
||||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
|
||||||
} else if (!isBuiltinFilter) {
|
|
||||||
const folder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
|
||||||
}
|
|
||||||
if (libraryTagFilter.length > 0)
|
|
||||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
|
||||||
if (search.trim()) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
|
||||||
|
|
||||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
|
||||||
// We need to know columns to chunk filtered into rows.
|
|
||||||
// Use a ResizeObserver on the scroll container to get real width.
|
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
|
||||||
setContainerWidth(entry.contentRect.width);
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const result: Manga[][] = [];
|
|
||||||
for (let i = 0; i < filtered.length; i += cols)
|
|
||||||
result.push(filtered.slice(i, i + cols));
|
|
||||||
return result;
|
|
||||||
}, [filtered, cols]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
estimateSize: () => ROW_HEIGHT,
|
|
||||||
overscan: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
|
||||||
(m: Manga) => () => setActiveManga(m),
|
|
||||||
[setActiveManga]
|
|
||||||
);
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault();
|
|
||||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
|
||||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
|
||||||
setCtx({ x, y, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "Open",
|
|
||||||
icon: <BookOpen size={13} weight="light" />,
|
|
||||||
onClick: () => setActiveManga(m),
|
|
||||||
},
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
danger: m.inLibrary,
|
|
||||||
onClick: () => m.inLibrary
|
|
||||||
? removeFromLibrary(m)
|
|
||||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete all downloads",
|
|
||||||
icon: <Trash size={13} weight="light" />,
|
|
||||||
danger: true,
|
|
||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
|
||||||
onClick: () => deleteAllDownloads(m),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const tagSet = new Set<string>();
|
|
||||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
|
||||||
return Array.from(tagSet).sort();
|
|
||||||
}, [allManga]);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const result: Record<string, number> = {
|
|
||||||
all: allManga.length,
|
|
||||||
library: allManga.filter((m) => m.inLibrary).length,
|
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
};
|
|
||||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
|
||||||
return result;
|
|
||||||
}, [allManga, folders]);
|
|
||||||
|
|
||||||
if (error) return (
|
|
||||||
<div className={s.center}>
|
|
||||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
|
||||||
<p className={s.errorDetail}>{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root} ref={scrollRef}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<div className={s.headerLeft}>
|
|
||||||
<h1 className={s.heading}>Library</h1>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(f)}
|
|
||||||
>
|
|
||||||
{f === "library" ? (
|
|
||||||
<><Books size={11} weight="bold" /> Saved</>
|
|
||||||
) : f === "downloaded" ? (
|
|
||||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
|
||||||
) : <>All</>}
|
|
||||||
<span className={s.tabCount}>{counts[f]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{folders.filter((f) => f.showTab).map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(folder.id)}
|
|
||||||
>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{folder.name}
|
|
||||||
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
className={s.search}
|
|
||||||
placeholder="Search"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allTags.length > 0 && (
|
|
||||||
<div className={s.tagPanel}>
|
|
||||||
{libraryTagFilter.length > 0 && (
|
|
||||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
|
||||||
<X size={11} weight="bold" /> Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{allTags.map((tag) => {
|
|
||||||
const active = libraryTagFilter.includes(tag);
|
|
||||||
return (
|
|
||||||
<button key={tag}
|
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
|
||||||
onClick={() => setLibraryTagFilter(
|
|
||||||
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
|
||||||
)}>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
|
||||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className={s.center}>
|
|
||||||
{libraryFilter === "library"
|
|
||||||
? "No manga saved to library. Browse sources to add some."
|
|
||||||
: libraryFilter === "downloaded"
|
|
||||||
? "No downloaded manga."
|
|
||||||
: !isBuiltinFilter
|
|
||||||
? "No manga in this folder yet. Right-click manga to assign them."
|
|
||||||
: "No manga found."}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Virtual scroll container */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: virtualizer.getTotalSize(),
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const rowManga = rows[virtualRow.index];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: virtualRow.start,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
className={s.virtualRow}
|
|
||||||
>
|
|
||||||
{rowManga.map((m) => (
|
|
||||||
<MangaCard
|
|
||||||
key={m.id}
|
|
||||||
manga={m}
|
|
||||||
onClick={handleCardClick(m)}
|
|
||||||
onContextMenu={(e) => openCtx(e, m)}
|
|
||||||
cropCovers={settings.libraryCropCovers}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Ghost cards on last row to fill grid */}
|
|
||||||
{virtualRow.index === rows.length - 1 &&
|
|
||||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
|
||||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu
|
|
||||||
x={ctx.x}
|
|
||||||
y={ctx.y}
|
|
||||||
items={buildCtxItems(ctx.manga)}
|
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
width: 520px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitle {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleManga {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
/* ── Steps ── */
|
|
||||||
.steps {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-3) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
opacity: 0.4;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive { opacity: 1; }
|
|
||||||
.stepDone { opacity: 0.6; }
|
|
||||||
|
|
||||||
.stepDot {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepDot {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepLabel { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.steps .step + .step::before {
|
|
||||||
content: "›";
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-right: var(--sp-1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Body ── */
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Source list ── */
|
|
||||||
.sourceList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 9px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
.sourceIcon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
||||||
|
|
||||||
.sourceName {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceMeta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceArrow {
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.sourceRow:hover .sourceArrow { opacity: 1; }
|
|
||||||
|
|
||||||
/* ── Search step ── */
|
|
||||||
.searchStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBar {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-2);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
padding: 7px 0;
|
|
||||||
}
|
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.searchBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.backBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
|
||||||
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.resultCoverWrap {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.resultTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeletons */
|
|
||||||
.skResult {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skCover {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
/* ── Confirm step ── */
|
|
||||||
.confirmStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmManga {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex: 1;
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCoverWrap {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2/3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.confirmTitle {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmSource {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.confirmStats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statVal {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmNote {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migrateBtn {
|
|
||||||
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 16px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
|
||||||
}
|
|
||||||