mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a8b6e7f93 | |||
| a452cdc2e3 | |||
| 71a6eb02b5 | |||
| 50e981574a | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead |
@@ -1,66 +0,0 @@
|
|||||||
name: Build AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version tag (e.g. 0.1.0)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
|
||||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
|
||||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
patchelf \
|
|
||||||
file
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Setup Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: pnpm tauri build --bundles appimage
|
|
||||||
env:
|
|
||||||
NO_STRIP: "true"
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
|
||||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.3.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
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
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
find_launcher() {
|
||||||
|
local dir="$1"
|
||||||
|
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||||
|
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||||
|
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
||||||
|
|
||||||
|
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
||||||
|
echo "ERROR: could not find launchers — tarball contents:"
|
||||||
|
ls -lR suwayomi-arm64 suwayomi-x64
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "arm64 launcher: $ARM_LAUNCHER"
|
||||||
|
echo "x64 launcher: $X64_LAUNCHER"
|
||||||
|
|
||||||
|
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||||
|
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||||
|
|
||||||
|
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
|
||||||
|
# We stage both arch bundles and swap the symlink before each build.
|
||||||
|
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
|
||||||
|
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
# dist/ is already built by the frontend job — suppress the rebuild.
|
||||||
|
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
||||||
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Swap bundle for aarch64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||||
|
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
with:
|
||||||
|
args: --target aarch64-apple-darwin
|
||||||
|
|
||||||
|
- name: Swap bundle for x86_64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||||
|
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
with:
|
||||||
|
args: --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Upload arm64 .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-aarch64
|
||||||
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload x64 .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-x86_64
|
||||||
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload arm64 .app (for universal job)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-aarch64-apple-darwin
|
||||||
|
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload x64 .app (for universal job)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-x86_64-apple-darwin
|
||||||
|
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
universal:
|
||||||
|
name: Universal .dmg
|
||||||
|
needs: tauri
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download arm64 .app
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-aarch64-apple-darwin
|
||||||
|
path: apps/arm64/
|
||||||
|
|
||||||
|
- name: Download x64 .app
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-x86_64-apple-darwin
|
||||||
|
path: apps/x64/
|
||||||
|
|
||||||
|
- name: lipo into universal binary
|
||||||
|
run: |
|
||||||
|
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
||||||
|
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
||||||
|
APP_NAME=$(basename "$ARM_APP")
|
||||||
|
|
||||||
|
mkdir -p universal
|
||||||
|
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
||||||
|
|
||||||
|
find "universal/${APP_NAME}" -type f | while read -r f; do
|
||||||
|
if file "$f" | grep -q "Mach-O"; then
|
||||||
|
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
||||||
|
if [ -f "$X64_EQUIV" ]; then
|
||||||
|
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Package universal .dmg
|
||||||
|
run: |
|
||||||
|
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
||||||
|
mkdir dmg-stage
|
||||||
|
cp -r "universal/${APP_NAME}" dmg-stage/
|
||||||
|
ln -s /Applications dmg-stage/Applications
|
||||||
|
hdiutil create \
|
||||||
|
-volname "Moku" \
|
||||||
|
-srcfolder dmg-stage \
|
||||||
|
-ov -format UDZO \
|
||||||
|
"moku-universal.dmg"
|
||||||
|
|
||||||
|
- name: Upload universal .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-universal
|
||||||
|
path: moku-universal.dmg
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
|
required: true
|
||||||
|
branch:
|
||||||
|
description: "Branch to build (e.g. svelte-rewrite)"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
|
||||||
|
- 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
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.branch }}
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d)
|
||||||
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f)
|
||||||
|
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
|
||||||
|
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
|
||||||
|
mv "$TOP_DIRS"/* suwayomi-extracted/
|
||||||
|
else
|
||||||
|
mv suwayomi-raw/* suwayomi-extracted/
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAVAW" ]; then
|
||||||
|
echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:"
|
||||||
|
find suwayomi-extracted -type f | head -40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found javaw: $JAVAW"
|
||||||
|
|
||||||
|
# Copy full bundle so jar + jre tree are available at runtime.
|
||||||
|
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir.
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Build Tauri app (Windows x64)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: >-
|
||||||
|
--target x86_64-pc-windows-msvc
|
||||||
|
--config '{"bundle":{"resources":["binaries/suwayomi-bundle/**"]}}'
|
||||||
|
|
||||||
|
- name: Upload Windows installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: moku-windows-x64
|
||||||
|
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||||
|
retention-days: 7
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.2.0
|
pkgver=0.3.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')
|
||||||
@@ -22,7 +22,7 @@ source=(
|
|||||||
"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=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,37 @@
|
|||||||
Todo:
|
Todo:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
3. Explore Manga Upscaler & Other Image Processing
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
4. Font Weird on Flatpak, Investigate and Fix
|
||||||
5. Investigate "egl:failed to create dri2 screen"
|
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||||
|
|
||||||
|
|
||||||
Bugs:
|
Bugs:
|
||||||
|
|
||||||
-
|
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
|
||||||
- Add Back after Search & Clear on Search
|
- Add Back after Search & Clear on Search
|
||||||
- Add as Package in Nix Flake & Check Later
|
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
|
|
||||||
|
|
||||||
- Fix Mangafire Main Dispatcher Issue
|
|
||||||
|
|
||||||
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||||
|
|
||||||
- Clean up Migrate Model to be more initutive
|
|
||||||
|
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
||||||
|
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
||||||
|
skip chapters, etc
|
||||||
|
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
||||||
|
- Reset to top when user clicks next chapter in reader.
|
||||||
|
|
||||||
|
|
||||||
|
- Fix Downloaded in Library (Tags Broken) & All
|
||||||
|
- Using Delete All Crashes App (But Works)
|
||||||
|
- Fix Folder Display in Library
|
||||||
|
- Add Version Tags (To Find Version)
|
||||||
|
- Sidebar Icon Highlighted
|
||||||
|
- Introduce Deduplication into Library & Search
|
||||||
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Add PDF Textbook Support
|
- Add PDF Textbook Support
|
||||||
- Major revision to disable entire manga-subsection and use as
|
|
||||||
solely as a reader/document launcher.
|
|
||||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Migration Features
|
- Migration Features
|
||||||
- Multi-Page Long Screenshot
|
- Multi-Page Long Screenshot
|
||||||
-
|
- Add Consumet Api (Anime & Light Novel Support)
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
Big Revisions:
|
||||||
@@ -47,14 +44,9 @@ Big Revisions:
|
|||||||
|
|
||||||
|
|
||||||
Testing:
|
Testing:
|
||||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
|
||||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
- Fix the Mark as Read (Glitched)
|
||||||
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
|
||||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
Completed:
|
||||||
@@ -74,6 +66,29 @@ Completed:
|
|||||||
18. Disable NSFW Extensions option in settings
|
18. Disable NSFW Extensions option in settings
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||||
- Remove Series Detail Mark Read & Unread
|
- Remove Series Detail Mark Read & Unread
|
||||||
|
20. Expand History (Total Time Read, etc)
|
||||||
|
12. Delete all Downloads should also cancel all download queues
|
||||||
|
13. Cancel Download along with Queue & Download Timeout Feature
|
||||||
|
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||||
|
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||||
|
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||||
|
- Extensions Page no Longer Loading efficiently
|
||||||
|
- Map out MangaPreview tags to GenreDrill
|
||||||
|
- GenreDrill & GenreFilter pages do not populate completely.
|
||||||
|
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||||
|
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||||
|
- Clean up Migrate Model to be more initutive
|
||||||
|
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||||
|
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||||
|
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||||
|
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||||
|
- Properly Kill Tachidesk-Server
|
||||||
|
- Fix scaling on splash screen
|
||||||
|
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
||||||
|
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
||||||
|
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-scripts/pkgbuild-bump.sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Run this AFTER the git tag has been pushed to GitHub.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-scripts/pkgbuild-bump.sh 0.3.0
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||||
|
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||||
|
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||||
|
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||||
|
VERSION="$1"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||||
|
|
||||||
|
command -v curl &>/dev/null || die "curl not found"
|
||||||
|
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||||
|
|
||||||
|
section "Patching PKGBUILD → ${VERSION}"
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v${VERSION}.tar.gz"
|
||||||
|
info "Fetching source tarball to compute sha256…"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||||
|
|
||||||
|
# Replace only the first sha256 entry (source tarball) inside sha256sums=('...')
|
||||||
|
# The suwayomi jar and jdk hashes are pinned and stay untouched.
|
||||||
|
# Strategy: match the opening sha256sums=('' then swap just that first hash.
|
||||||
|
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1${TARBALL_SHA}/" "$PKGBUILD"
|
||||||
|
|
||||||
|
# Verify the replacement landed
|
||||||
|
if ! grep -q "$TARBALL_SHA" "$PKGBUILD"; then
|
||||||
|
die "sha256 replacement failed — check PKGBUILD sha256sums format"
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "PKGBUILD patched (pkgver=${VERSION}, sha256=${TARBALL_SHA})"
|
||||||
|
info "PKGBUILD → ${PKGBUILD}"
|
||||||
+51
-122
@@ -2,18 +2,14 @@
|
|||||||
# build-scripts/release.sh
|
# build-scripts/release.sh
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
# ./build-scripts/release.sh 0.2.0
|
||||||
# ./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)
|
# Requires: nix, flatpak-builder, appstream
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
||||||
@@ -21,31 +17,23 @@ die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
|||||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||||
|
|
||||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
# ── Args ───────────────────────────────────────────────────────────────────────
|
||||||
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
|
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||||
|
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
MODE="${2:-all}"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && 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"
|
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||||
|
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||||
|
|
||||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||||
section "Pre-flight"
|
section "Pre-flight"
|
||||||
command -v nix &>/dev/null || die "nix not found"
|
command -v nix &>/dev/null || die "nix not found"
|
||||||
|
command -v curl &>/dev/null || die "curl not found"
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
[[ -f "$FLATPAK_MANIFEST" ]] || die "Flatpak manifest not found: $FLATPAK_MANIFEST"
|
||||||
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||||
[[ -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"
|
success "OK"
|
||||||
|
|
||||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
# ── Bump versions ──────────────────────────────────────────────────────────────
|
||||||
section "Bumping version → ${VERSION}"
|
section "Bumping version → ${VERSION}"
|
||||||
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
||||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
||||||
success "tauri.conf.json → ${VERSION}"
|
success "tauri.conf.json → ${VERSION}"
|
||||||
@@ -54,6 +42,12 @@ sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
|||||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
||||||
success "Cargo.toml → ${VERSION}"
|
success "Cargo.toml → ${VERSION}"
|
||||||
|
|
||||||
|
# flake.nix has two `version = "x.y.z";` strings inside the frontend
|
||||||
|
# derivation and fetchPnpmDeps — both need to match.
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"${VERSION}\";/g" \
|
||||||
|
"${REPO_ROOT}/flake.nix"
|
||||||
|
success "flake.nix → ${VERSION}"
|
||||||
|
|
||||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
# ── Build frontend ─────────────────────────────────────────────────────────────
|
||||||
section "Building frontend"
|
section "Building frontend"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
@@ -61,37 +55,26 @@ nix develop --command pnpm install --frozen-lockfile
|
|||||||
nix develop --command pnpm build
|
nix develop --command pnpm build
|
||||||
success "Frontend built → dist/"
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
section "Regenerating cargo-sources.json"
|
||||||
section "Regenerating cargo-sources.json"
|
cd "$REPO_ROOT"
|
||||||
cd "$REPO_ROOT"
|
nix-shell \
|
||||||
nix-shell \
|
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
||||||
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||||
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
success "cargo-sources.json updated"
|
||||||
success "cargo-sources.json updated"
|
|
||||||
|
|
||||||
section "Rebuilding frontend-dist.tar.gz"
|
section "Rebuilding frontend-dist.tar.gz"
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
||||||
|
|
||||||
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
section "Patching frontend-dist sha256 in dev.moku.app.yml"
|
||||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
||||||
cat > "$PATCH_SCRIPT" << PYEOF
|
cat > "$PATCH_SCRIPT" << PYEOF
|
||||||
import re, sys
|
import re, sys
|
||||||
|
|
||||||
path = "${FLATPAK_MANIFEST}"
|
path = "${FLATPAK_MANIFEST}"
|
||||||
new_sha = "${FRONTEND_SHA}"
|
new_sha = "${FRONTEND_SHA}"
|
||||||
text = open(path).read()
|
text = open(path).read()
|
||||||
|
|
||||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
||||||
replacement = r'\g<1>' + new_sha
|
replacement = r'\g<1>' + new_sha
|
||||||
updated, n = re.subn(pattern, replacement, text)
|
updated, n = re.subn(pattern, replacement, text)
|
||||||
@@ -99,86 +82,32 @@ if n == 0:
|
|||||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
||||||
open(path, 'w').write(updated)
|
open(path, 'w').write(updated)
|
||||||
PYEOF
|
PYEOF
|
||||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
||||||
rm -f "$PATCH_SCRIPT"
|
rm -f "$PATCH_SCRIPT"
|
||||||
success "dev.moku.app.yml sha256 updated"
|
success "dev.moku.app.yml sha256 updated"
|
||||||
|
|
||||||
section "Building Flatpak bundle"
|
section "Building Flatpak bundle"
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
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"
|
||||||
|
|
||||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
flatpak build-bundle \
|
||||||
flatpak-builder \
|
"${REPO_ROOT}/repo" \
|
||||||
--repo="${REPO_ROOT}/repo" \
|
"${REPO_ROOT}/moku.flatpak" \
|
||||||
--force-clean \
|
dev.moku.app
|
||||||
"${REPO_ROOT}/build-dir" \
|
|
||||||
"$FLATPAK_MANIFEST"
|
|
||||||
|
|
||||||
flatpak build-bundle \
|
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||||
"${REPO_ROOT}/repo" \
|
success "moku.flatpak created"
|
||||||
"${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
|
|
||||||
|
|
||||||
|
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
success "v${VERSION} ready"
|
success "v${VERSION} ready"
|
||||||
|
info "Flatpak bundle → ${REPO_ROOT}/moku.flatpak"
|
||||||
|
echo ""
|
||||||
|
warn "PKGBUILD not patched yet — tag must exist on GitHub first."
|
||||||
|
info "After pushing the tag, run:"
|
||||||
|
echo -e " ${CYAN}./build-scripts/pkgbuild-bump.sh ${VERSION}${RESET}"
|
||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
sha256: c9bb5ee6613b2bc61e69a92cc1ef0029da3b61138d51b01d363f8ea524e51996
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
frontend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
version = "0.1.0";
|
version = "0.3.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pname = "moku-frontend";
|
pname = "moku-frontend";
|
||||||
version = "0.1.0";
|
version = "0.3.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||||
@@ -135,11 +135,67 @@
|
|||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||||
--set GDK_BACKEND wayland \
|
--set GDK_BACKEND wayland \
|
||||||
--set WEBKIT_FORCE_SANDBOX 0
|
--set WEBKIT_FORCE_SANDBOX 0
|
||||||
|
|
||||||
|
# ── Icon ─────────────────────────────────────────────────────────
|
||||||
|
# Tauri bakes several sizes into src-tauri/icons/. We prefer the
|
||||||
|
# largest PNG (512x512) for the hicolor theme, and also install the
|
||||||
|
# rounded 32x32 used as the in-app logo so small sizes look right.
|
||||||
|
# Adjust the source filenames if yours differ.
|
||||||
|
for size in 32x32 128x128 256x256 512x512; do
|
||||||
|
src="icons/$size.png"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# @2x variants that Tauri also generates
|
||||||
|
for size in 128x128 256x256; do
|
||||||
|
src="icons/''${size}@2x.png"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
install -Dm644 "$src" \
|
||||||
|
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Scalable SVG — src/assets/moku-icon.svg is the rounded version
|
||||||
|
# referenced in SplashScreen.tsx. Pull it straight from the source
|
||||||
|
# tree so the launcher always uses the same rounded artwork.
|
||||||
|
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||||
|
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||||
|
|
||||||
|
# ── .desktop entry ───────────────────────────────────────────────
|
||||||
|
install -Dm644 /dev/stdin \
|
||||||
|
"$out/share/applications/moku.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Moku
|
||||||
|
Comment=Manga reader frontend for Suwayomi
|
||||||
|
Exec=$out/bin/moku
|
||||||
|
Icon=moku
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Viewer;
|
||||||
|
Keywords=manga;comic;reader;suwayomi;
|
||||||
|
StartupWMClass=moku
|
||||||
|
EOF
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
# Expose as both a runnable app and installable packages.
|
||||||
|
apps = {
|
||||||
|
default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${moku}/bin/moku";
|
||||||
|
};
|
||||||
|
moku = {
|
||||||
|
type = "app";
|
||||||
|
program = "${moku}/bin/moku";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
packages = {
|
packages = {
|
||||||
inherit moku frontend;
|
inherit moku frontend;
|
||||||
default = moku;
|
default = moku;
|
||||||
|
|||||||
Binary file not shown.
Generated
+136
-33
@@ -285,12 +285,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg_aliases"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
@@ -396,6 +390,25 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -645,6 +658,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -1797,12 +1816,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"nix",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sysinfo",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
@@ -1866,24 +1885,21 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nix"
|
|
||||||
version = "0.29.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"cfg-if",
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2624,6 +2640,26 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -3242,6 +3278,20 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"rayon",
|
||||||
|
"windows 0.57.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3289,7 +3339,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3360,7 +3410,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3486,7 +3536,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3512,7 +3562,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4241,10 +4291,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4265,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4315,6 +4365,16 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.57.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -4337,14 +4397,26 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement 0.57.0",
|
||||||
|
"windows-interface 0.57.0",
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.3.4",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.4.2",
|
||||||
@@ -4356,8 +4428,8 @@ version = "0.62.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.4.1",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
@@ -4374,6 +4446,17 @@ dependencies = [
|
|||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -4385,6 +4468,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.59.3"
|
version = "0.59.3"
|
||||||
@@ -4418,6 +4512,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4921,7 +5024,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -20,7 +20,7 @@ tauri-plugin-shell = "2"
|
|||||||
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"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Allow launching tachidesk-server",
|
"description": "Allow launching suwayomi-server sidecar",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
|
"shell:allow-kill",
|
||||||
{
|
{
|
||||||
"identifier": "shell:allow-spawn",
|
"identifier": "shell:allow-spawn",
|
||||||
"allow": [
|
"allow": [
|
||||||
{
|
{
|
||||||
"name": "tachidesk-server",
|
"name": "binaries/suwayomi-server",
|
||||||
"cmd": "tachidesk-server"
|
"sidecar": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-14
@@ -1,6 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use sysinfo::Disks;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||||
@@ -23,9 +23,8 @@ fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|||||||
let base = std::env::var("XDG_DATA_HOME")
|
let base = std::env::var("XDG_DATA_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
dirs::home_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("/"))
|
.unwrap_or_else(|| PathBuf::from("/"))
|
||||||
.join(".local/share")
|
|
||||||
});
|
});
|
||||||
base.join("Tachidesk/downloads")
|
base.join("Tachidesk/downloads")
|
||||||
}
|
}
|
||||||
@@ -49,11 +48,16 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
let stat_path = if path.exists() { path.clone() } else {
|
let stat_path = if path.exists() { path.clone() } else {
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
};
|
};
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let frsize = vfs.fragment_size() as u64;
|
let disks = Disks::new_with_refreshed_list();
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
let disk = disks
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
.iter()
|
||||||
|
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||||
|
|
||||||
|
let total_bytes = disk.total_space();
|
||||||
|
let free_bytes = disk.available_space();
|
||||||
|
|
||||||
Ok(StorageInfo {
|
Ok(StorageInfo {
|
||||||
manga_bytes,
|
manga_bytes,
|
||||||
@@ -64,10 +68,8 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the true OS-level scale factor for the main window.
|
/// Returns the true OS-level scale factor for the main window.
|
||||||
/// This reads directly from the underlying winit window handle, bypassing
|
/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio.
|
||||||
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
/// On macOS the value comes directly from the native window.
|
||||||
/// This is the only reliable way to get the correct DPR in all launch
|
|
||||||
/// environments — tauri dev, nix run, flatpak, etc.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||||
window.scale_factor().unwrap_or(1.0)
|
window.scale_factor().unwrap_or(1.0)
|
||||||
@@ -80,12 +82,200 @@ fn kill_tachidesk(app: &tauri::AppHandle) {
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
println!("Killed tracked server child.");
|
println!("Killed tracked server child.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/FI", "IMAGENAME eq tachidesk*"])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("tachidesk")
|
.arg("tachidesk")
|
||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default server.conf we seed on first launch.
|
||||||
|
/// Mirrors the Flatpak wrapper: headless, no tray, no browser pop-up.
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
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 = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Ensure the Suwayomi data dir and server.conf exist, and that the three
|
||||||
|
/// keys that cause GUI/JCEF crashes are always set to safe values.
|
||||||
|
/// This mirrors the shell-script logic in the Flatpak's tachidesk-server wrapper.
|
||||||
|
fn seed_server_conf(data_dir: &PathBuf) {
|
||||||
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conf already exists — patch the three critical keys in-place.
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||||
|
|
||||||
|
let patched = patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
patch_conf_key(
|
||||||
|
contents,
|
||||||
|
"server.webUIEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.initialOpenInBrowserEnabled",
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
"server.systemTrayEnabled",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::write(&conf_path, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `key = <value>` in a HOCON/properties-style conf, or append it
|
||||||
|
/// if the key is absent.
|
||||||
|
fn patch_conf_key(mut text: String, key: &str, value: &str) -> String {
|
||||||
|
let replacement = format!("{key} = {value}");
|
||||||
|
// Find a line that starts with the key (tolerant of surrounding whitespace)
|
||||||
|
if let Some(pos) = text.lines().position(|l| l.trim_start().starts_with(key)) {
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
// We need an owned replacement; rebuild from scratch.
|
||||||
|
let owned: Vec<String> = lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| {
|
||||||
|
if i == pos { replacement.clone() } else { l.to_string() }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return owned.join("\n");
|
||||||
|
}
|
||||||
|
// Key absent — append.
|
||||||
|
if !text.ends_with('\n') { text.push('\n'); }
|
||||||
|
text.push_str(&replacement);
|
||||||
|
text.push('\n');
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the Suwayomi data directory.
|
||||||
|
///
|
||||||
|
/// - Linux: $XDG_DATA_HOME/moku/tachidesk (matches Flatpak path)
|
||||||
|
/// - macOS: ~/Library/Application Support/dev.moku.app/tachidesk
|
||||||
|
fn suwayomi_data_dir() -> PathBuf {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
|
.join("dev.moku.app/tachidesk")
|
||||||
|
}
|
||||||
|
#[cfg(not(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything needed to spawn the server process.
|
||||||
|
struct ServerInvocation {
|
||||||
|
/// Path to the executable (javaw.exe on Windows, the sidecar script on macOS/Linux).
|
||||||
|
bin: std::ffi::OsString,
|
||||||
|
/// Extra args prepended before the Suwayomi rootDir flag.
|
||||||
|
/// On Windows: ["-jar", "<path-to-jar>"]
|
||||||
|
/// Elsewhere: []
|
||||||
|
prefix_args: Vec<String>,
|
||||||
|
/// Working directory for the child process.
|
||||||
|
/// On Windows this must be the bundle folder so javaw can find the JRE and jar.
|
||||||
|
/// Elsewhere: None (inherit).
|
||||||
|
working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the server binary path.
|
||||||
|
///
|
||||||
|
/// If the frontend passes a non-empty `binary` string (user override in
|
||||||
|
/// Settings) we always use that — on Linux this is the nixpkgs/Flatpak path.
|
||||||
|
///
|
||||||
|
/// Otherwise we look for the Tauri-bundled sidecar inside the resource dir
|
||||||
|
/// and, on Windows, build the javaw + jar invocation from the suwayomi-bundle.
|
||||||
|
fn resolve_server_binary(
|
||||||
|
binary: &str,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
) -> Result<ServerInvocation, String> {
|
||||||
|
if !binary.trim().is_empty() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: std::ffi::OsString::from(binary),
|
||||||
|
prefix_args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource_dir = app
|
||||||
|
.path()
|
||||||
|
.resource_dir()
|
||||||
|
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
|
||||||
|
|
||||||
|
// ── Windows: invoke the bundled javaw.exe with -jar Suwayomi-Launcher.jar ──
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let sidecar = resource_dir.join("suwayomi-server-x86_64-pc-windows-msvc.exe");
|
||||||
|
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||||
|
let jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||||
|
|
||||||
|
if sidecar.exists() && jar.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: sidecar.into_os_string(),
|
||||||
|
prefix_args: vec![
|
||||||
|
"-jar".to_string(),
|
||||||
|
jar.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── macOS / Linux: sidecar script is self-contained ──
|
||||||
|
let candidates = [
|
||||||
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
// plain name as a dev/Linux fallback
|
||||||
|
"suwayomi-server",
|
||||||
|
];
|
||||||
|
|
||||||
|
for name in &candidates {
|
||||||
|
let p = resource_dir.join(name);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: p.into_os_string(),
|
||||||
|
prefix_args: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Suwayomi server binary not found. Please set the path in Settings.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
@@ -97,21 +287,44 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed server.conf before launching so Suwayomi starts in headless mode.
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
seed_server_conf(&data_dir);
|
||||||
|
|
||||||
|
let invocation = resolve_server_binary(&binary, &app)?;
|
||||||
let shell = app.shell();
|
let shell = app.shell();
|
||||||
match shell.command(&binary).spawn() {
|
|
||||||
|
let rootdir_flag = format!(
|
||||||
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
data_dir.to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the full arg list: prefix_args (e.g. -jar foo.jar) + rootDir flag.
|
||||||
|
let args: Vec<String> = invocation.prefix_args.into_iter().chain(std::iter::once(rootdir_flag)).collect();
|
||||||
|
|
||||||
|
// On Windows, set the working directory to the bundle folder so javaw.exe
|
||||||
|
// can resolve the JRE and jar relative paths correctly.
|
||||||
|
let cmd = shell
|
||||||
|
.command(&invocation.bin)
|
||||||
|
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||||
|
.args(&args)
|
||||||
|
.current_dir(invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()));
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
println!("Spawned server: {}", binary);
|
println!("Spawned server: {:?}", invocation.bin);
|
||||||
let mut guard = state.0.lock().unwrap();
|
let mut guard = state.0.lock().unwrap();
|
||||||
*guard = Some(child);
|
*guard = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to spawn {}: {}", binary, e);
|
eprintln!("Failed to spawn {:?}: {}", invocation.bin, e);
|
||||||
Err(e.to_string())
|
Err(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
kill_tachidesk(&app);
|
kill_tachidesk(&app);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -40,4 +40,4 @@
|
|||||||
"open": true
|
"open": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-2
@@ -36,6 +36,7 @@ export default function App() {
|
|||||||
|
|
||||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const idleRef = useRef(false);
|
||||||
|
|
||||||
// expose devSplash trigger via window for settings
|
// expose devSplash trigger via window for settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,10 +44,15 @@ export default function App() {
|
|||||||
return () => { delete (window as any).__mokuShowSplash; };
|
return () => { delete (window as any).__mokuShowSplash; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keep idleRef in sync so resetIdle can check it without a stale closure
|
||||||
|
useEffect(() => { idleRef.current = idle; }, [idle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appReady) return;
|
if (!appReady) return;
|
||||||
function resetIdle() {
|
function resetIdle() {
|
||||||
setIdle(false);
|
// While the idle splash is visible, don't reset — let SplashScreen's own
|
||||||
|
// dismiss flow handle teardown so the exit animation plays fully.
|
||||||
|
if (idleRef.current) return;
|
||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
if (idleTimeoutMs === 0) return;
|
if (idleTimeoutMs === 0) return;
|
||||||
@@ -178,7 +184,7 @@ export default function App() {
|
|||||||
<SplashScreen
|
<SplashScreen
|
||||||
mode="idle"
|
mode="idle"
|
||||||
showCards={settings.splashCards ?? true}
|
showCards={settings.splashCards ?? true}
|
||||||
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!activeChapter && <TitleBar/>}
|
{!activeChapter && <TitleBar/>}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { UPDATE_MANGA } from "../../lib/queries";
|
|||||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import SourceList from "../sources/SourceList";
|
import SourceList from "../sources/SourceList";
|
||||||
@@ -177,6 +177,35 @@ export default function Explore() {
|
|||||||
|
|
||||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||||
|
|
||||||
|
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
|
||||||
|
const EXPLORE_ALL_MANGA = `
|
||||||
|
query ExploreAllManga {
|
||||||
|
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fast genre row query against the local DB
|
||||||
|
const MANGAS_BY_GENRE_EXPLORE = `
|
||||||
|
query MangasByGenreExplore($genre: String!, $first: Int) {
|
||||||
|
mangas(
|
||||||
|
filter: { genre: { includesInsensitive: $genre } }
|
||||||
|
first: $first
|
||||||
|
orderBy: IN_LIBRARY_AT
|
||||||
|
orderByType: DESC
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function ExploreFeed() {
|
function ExploreFeed() {
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
const [loadingLib, setLoadingLib] = useState(true);
|
const [loadingLib, setLoadingLib] = useState(true);
|
||||||
@@ -238,10 +267,11 @@ function ExploreFeed() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
|
// ── Data load ─────────────────────────────────────────────────────────────
|
||||||
|
// Library + genre rows: single local DB query each — instant, no source calls.
|
||||||
|
// Popular: still needs fetchSourceManga since there's no local equivalent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we already have data, no need to re-fetch (cache hit path)
|
const alreadyLoaded = allManga.length > 0;
|
||||||
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
|
||||||
if (alreadyLoaded) return;
|
if (alreadyLoaded) return;
|
||||||
|
|
||||||
setLoadingLib(true);
|
setLoadingLib(true);
|
||||||
@@ -249,39 +279,29 @@ function ExploreFeed() {
|
|||||||
setLoadError(false);
|
setLoadError(false);
|
||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
// Clear stale failed cache entries so we actually retry
|
|
||||||
if (retryCount > 0) {
|
if (retryCount > 0) {
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
cache.clear(CACHE_KEYS.SOURCES);
|
cache.clear(CACHE_KEYS.SOURCES);
|
||||||
fetchedGenresRef.current = "";
|
fetchedGenresRef.current = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library — fire immediately, independent of sources
|
// Single query for all manga — library flag included
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
Promise.all([
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
.then((d) => d.mangas.nodes)
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
|
||||||
]).then(([all, lib]) => {
|
|
||||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
|
||||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
|
||||||
})
|
|
||||||
).then(setAllManga)
|
).then(setAllManga)
|
||||||
.catch((e) => { console.error(e); setLoadError(true); })
|
.catch((e) => { console.error(e); setLoadError(true); })
|
||||||
.finally(() => setLoadingLib(false));
|
.finally(() => setLoadingLib(false));
|
||||||
|
|
||||||
// Sources — then kick off popular AND genres simultaneously
|
// Sources — only needed for Popular section
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||||
).then((allSources) => {
|
).then((allSources) => {
|
||||||
if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
|
if (allSources.length === 0) { setLoadingPopular(false); return; }
|
||||||
|
|
||||||
// Cap to 2 sources for the explore feed — halves the network calls
|
|
||||||
const topSources = getTopSources(allSources).slice(0, 2);
|
const topSources = getTopSources(allSources).slice(0, 2);
|
||||||
setSources(allSources);
|
setSources(allSources);
|
||||||
|
|
||||||
// ── Popular — don't block genres ──────────────────────────────────
|
|
||||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
topSources.map((src) =>
|
topSources.map((src) =>
|
||||||
@@ -296,48 +316,7 @@ function ExploreFeed() {
|
|||||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||||
})
|
})
|
||||||
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||||
|
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||||
// ── Genres — start immediately alongside popular using foundational
|
|
||||||
// genres as a starting point; personalized genres replace these once
|
|
||||||
// library loads. Results stream in as each genre resolves.
|
|
||||||
const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
const genreKey = genresToFetch.join(",");
|
|
||||||
if (fetchedGenresRef.current === genreKey) return;
|
|
||||||
fetchedGenresRef.current = genreKey;
|
|
||||||
|
|
||||||
setLoadingGenres(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
const streamingMap = new Map<string, Manga[]>();
|
|
||||||
Promise.allSettled(
|
|
||||||
genresToFetch.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
Promise.allSettled(
|
|
||||||
topSources.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
|
||||||
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 24);
|
|
||||||
})
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
// Stream: each genre paints immediately as it resolves
|
|
||||||
streamingMap.set(genre, mangas);
|
|
||||||
setGenreResults(new Map(streamingMap));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
|
||||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
|
||||||
})
|
|
||||||
.catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [retryCount]);
|
}, [retryCount]);
|
||||||
|
|
||||||
@@ -367,12 +346,13 @@ function ExploreFeed() {
|
|||||||
.map(([g]) => g);
|
.map(([g]) => g);
|
||||||
}, [allManga, history]);
|
}, [allManga, history]);
|
||||||
|
|
||||||
// ── Re-fetch only when personalized genres differ from what's cached ───────
|
// ── Genre rows: query local DB directly ─────────────────────────────────
|
||||||
|
// One query per genre against the local mangas table — instant, no source I/O.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
if (frecencyGenres.length === 0 || allManga.length === 0) return;
|
||||||
|
|
||||||
const genreKey = frecencyGenres.join(",");
|
const genreKey = frecencyGenres.join(",");
|
||||||
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
|
if (fetchedGenresRef.current === genreKey) return;
|
||||||
fetchedGenresRef.current = genreKey;
|
fetchedGenresRef.current = genreKey;
|
||||||
|
|
||||||
setLoadingGenres(true);
|
setLoadingGenres(true);
|
||||||
@@ -380,24 +360,16 @@ function ExploreFeed() {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
const topSources = getTopSources(sources).slice(0, 2);
|
|
||||||
const streamingMap = new Map<string, Manga[]>();
|
const streamingMap = new Map<string, Manga[]>();
|
||||||
|
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
frecencyGenres.map((genre) =>
|
frecencyGenres.map((genre) =>
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||||
Promise.allSettled(
|
gql<{ mangas: { nodes: Manga[] } }>(
|
||||||
topSources.map((src) =>
|
MANGAS_BY_GENRE_EXPLORE,
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
{ genre, first: 25 },
|
||||||
source: src.id, type: "SEARCH", page: 1, query: genre,
|
ctrl.signal,
|
||||||
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
).then((d) => d.mangas.nodes)
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 24);
|
|
||||||
})
|
|
||||||
).then((mangas) => {
|
).then((mangas) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
streamingMap.set(genre, mangas);
|
streamingMap.set(genre, mangas);
|
||||||
@@ -407,7 +379,7 @@ function ExploreFeed() {
|
|||||||
)
|
)
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||||
}, [frecencyGenres, sources]);
|
}, [frecencyGenres, allManga]);
|
||||||
|
|
||||||
function openManga(m: Manga) { setPreviewManga(m); }
|
function openManga(m: Manga) { setPreviewManga(m); }
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,54 @@ import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
|||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import s from "./GenreDrillPage.module.css";
|
import s from "./GenreDrillPage.module.css";
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
const PAGE_SIZE = 50; // how many items to show at once
|
const PAGE_SIZE = 50;
|
||||||
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
const INITIAL_PAGES = 3;
|
||||||
const MAX_SOURCES = 12; // max sources to query concurrently
|
const MAX_SOURCES = 12;
|
||||||
const CONCURRENCY = 4; // parallel source fetches
|
const CONCURRENCY = 4;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
|
||||||
|
* multi-tag string ("Action+Romance"). Parse it into an array.
|
||||||
|
*
|
||||||
|
* Callers set multi-tag filters via:
|
||||||
|
* setGenreFilter("Action+Romance")
|
||||||
|
*
|
||||||
|
* The Explore feed's "See all" button continues to pass single strings and
|
||||||
|
* requires no change.
|
||||||
|
*/
|
||||||
|
function parseTags(genreFilter: string): string[] {
|
||||||
|
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
|
||||||
|
function tagsLabel(tags: string[]): string {
|
||||||
|
if (tags.length === 1) return tags[0];
|
||||||
|
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side AND filter.
|
||||||
|
* Sources only accept a single query string, so we send the first tag and
|
||||||
|
* drop results that don't also have the remaining tags in their genre list.
|
||||||
|
*/
|
||||||
|
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||||
|
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||||
|
return tags.every((t) => genres.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
async function runConcurrent<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
fn: (item: T) => Promise<void>,
|
fn: (item: T) => Promise<void>,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -46,7 +78,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string;
|
|||||||
|
|
||||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||||
export default function GenreDrillPage() {
|
export default function GenreDrillPage() {
|
||||||
const genre = useStore((st) => st.genreFilter);
|
const genreFilter = useStore((st) => st.genreFilter);
|
||||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
const settings = useStore((st) => st.settings);
|
const settings = useStore((st) => st.settings);
|
||||||
@@ -54,6 +86,11 @@ export default function GenreDrillPage() {
|
|||||||
const addFolder = useStore((st) => st.addFolder);
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
|
||||||
|
// Parse the filter string into individual tags
|
||||||
|
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
|
||||||
|
// First tag is sent as the source query string (sources accept only one term)
|
||||||
|
const primaryTag = tags[0] ?? "";
|
||||||
|
|
||||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||||
@@ -62,12 +99,13 @@ export default function GenreDrillPage() {
|
|||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
// Per-source next-page tracker; -1 means exhausted
|
// Per-source next-page tracker; -1 means exhausted
|
||||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
const sourcesRef = useRef<Source[]>([]);
|
const sourcesRef = useRef<Source[]>([]);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// ── Initial load ─────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!genre) return;
|
if (tags.length === 0) return;
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -81,7 +119,7 @@ export default function GenreDrillPage() {
|
|||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
|
// ── Library (local DB, instant) ───────────────────────────────────────
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||||
@@ -94,46 +132,67 @@ export default function GenreDrillPage() {
|
|||||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||||
|
|
||||||
// ── Sources: stream results in as each source responds ────────────────
|
// ── Sources: stream results as each source responds ───────────────────
|
||||||
|
// Source list is stable within a session — cache indefinitely.
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
|
||||||
|
Infinity,
|
||||||
).then(async (allSources) => {
|
).then(async (allSources) => {
|
||||||
const sources = allSources.slice(0, MAX_SOURCES);
|
const sources = allSources.slice(0, MAX_SOURCES);
|
||||||
sourcesRef.current = sources;
|
sourcesRef.current = sources;
|
||||||
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
|
||||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||||
|
|
||||||
await runConcurrent(sources, async (src) => {
|
await runConcurrent(sources, async (src) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
|
||||||
|
// On navigation-away → back the pages are still in the TTL store, so fetchPage
|
||||||
|
// returns the cached promise immediately without hitting the network.
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||||
const pageItems: Manga[] = [];
|
const pageItems: Manga[] = [];
|
||||||
|
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||||
FETCH_SOURCE_MANGA,
|
const result = await cache
|
||||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
ctrl.signal,
|
pageKey,
|
||||||
);
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
pageItems.push(...d.fetchSourceManga.mangas);
|
FETCH_SOURCE_MANGA,
|
||||||
if (!d.fetchSourceManga.hasNextPage) {
|
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||||
nextPageRef.current.set(src.id, -1);
|
ctrl.signal,
|
||||||
break;
|
).then((d) => d.fetchSourceManga),
|
||||||
} else if (page === INITIAL_PAGES) {
|
)
|
||||||
// Has more pages beyond what we fetched upfront — mark for "load more"
|
.catch((e: any) => {
|
||||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
}
|
return null;
|
||||||
} catch (e: any) {
|
});
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
|
if (!result || ctrl.signal.aborted) break;
|
||||||
|
|
||||||
|
ps.add(page);
|
||||||
|
|
||||||
|
// For multi-tag searches: client-side AND filter for tags beyond the first.
|
||||||
|
// Sources only support a single query string, so we send primaryTag and
|
||||||
|
// drop results that don't contain the remaining tags in their genre array.
|
||||||
|
const matching = tags.length > 1
|
||||||
|
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||||
|
: result.mangas;
|
||||||
|
|
||||||
|
pageItems.push(...matching);
|
||||||
|
|
||||||
|
if (!result.hasNextPage) {
|
||||||
nextPageRef.current.set(src.id, -1);
|
nextPageRef.current.set(src.id, -1);
|
||||||
break;
|
break;
|
||||||
|
} else if (page === INITIAL_PAGES) {
|
||||||
|
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||||
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
|
||||||
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||||
// Drop the skeleton as soon as we have anything
|
|
||||||
setLoadingInitial(false);
|
setLoadingInitial(false);
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
@@ -145,34 +204,35 @@ export default function GenreDrillPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => { ctrl.abort(); };
|
return () => { ctrl.abort(); };
|
||||||
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
// genreFilter (not tags) as the dep — tags is derived from it and would
|
||||||
|
// cause an extra render on every parse; genreFilter is the stable identity.
|
||||||
|
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Derived merged list ────────────────────────────────────────────────────
|
// ── Derived merged list ───────────────────────────────────────────────────
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
// For multi-tag: library results must match ALL tags
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||||
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...srcAll]);
|
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
|
||||||
}, [libraryManga, sourceManga, genre]);
|
return dedupeMangaById([...libMatches, ...srcOnly]);
|
||||||
|
}, [libraryManga, sourceManga, tags]);
|
||||||
|
|
||||||
// ── Load more ──────────────────────────────────────────────────────────────
|
// ── Load more ─────────────────────────────────────────────────────────────
|
||||||
const hasMoreVisible = visibleCount < filtered.length;
|
const hasMoreVisible = visibleCount < filtered.length;
|
||||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
|
|
||||||
// If there are buffered results, just reveal the next page
|
// Fast path: buffered results already in memory
|
||||||
if (hasMoreVisible) {
|
if (hasMoreVisible) {
|
||||||
setVisibleCount((v) => v + PAGE_SIZE);
|
setVisibleCount((v) => v + PAGE_SIZE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch next pages from network
|
// Slow path: fetch next pages from sources
|
||||||
const sources = sourcesRef.current.filter(
|
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
|
||||||
);
|
|
||||||
if (!sources.length) return;
|
if (!sources.length) return;
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
@@ -184,18 +244,35 @@ export default function GenreDrillPage() {
|
|||||||
await runConcurrent(sources, async (src) => {
|
await runConcurrent(sources, async (src) => {
|
||||||
const page = nextPageRef.current.get(src.id)!;
|
const page = nextPageRef.current.get(src.id)!;
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||||
FETCH_SOURCE_MANGA,
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
|
||||||
ctrl.signal,
|
const result = await cache
|
||||||
);
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
pageKey,
|
||||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
FETCH_SOURCE_MANGA,
|
||||||
} catch (e: any) {
|
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
ctrl.signal,
|
||||||
}
|
).then((d) => d.fetchSourceManga),
|
||||||
|
)
|
||||||
|
.catch((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
ps.add(page);
|
||||||
|
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
|
|
||||||
|
const matching = tags.length > 1
|
||||||
|
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||||
|
: result.mangas;
|
||||||
|
|
||||||
|
if (matching.length > 0)
|
||||||
|
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) {
|
||||||
@@ -203,7 +280,7 @@ export default function GenreDrillPage() {
|
|||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [loadingMore, hasMoreVisible, genre]);
|
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────
|
// ── Context menu ──────────────────────────────────────────────────────────
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
@@ -245,6 +322,7 @@ export default function GenreDrillPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visibleItems = filtered.slice(0, visibleCount);
|
const visibleItems = filtered.slice(0, visibleCount);
|
||||||
|
const label = tagsLabel(tags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
@@ -253,7 +331,7 @@ export default function GenreDrillPage() {
|
|||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
<span className={s.title}>{genre}</span>
|
<span className={s.title}>{label}</span>
|
||||||
{loadingInitial && filtered.length === 0 ? null : (
|
{loadingInitial && filtered.length === 0 ? null : (
|
||||||
<span className={s.resultCount}>
|
<span className={s.resultCount}>
|
||||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||||
@@ -274,7 +352,7 @@ export default function GenreDrillPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className={s.empty}>No manga found for "{genre}".</div>
|
<div className={s.empty}>No manga found for "{label}".</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={s.grid}>
|
<div className={s.grid}>
|
||||||
{visibleItems.map((m) => (
|
{visibleItems.map((m) => (
|
||||||
@@ -290,8 +368,8 @@ export default function GenreDrillPage() {
|
|||||||
<div className={s.showMoreCell}>
|
<div className={s.showMoreCell}>
|
||||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||||
{loadingMore
|
{loadingMore
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading…</>
|
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading…</>
|
||||||
: `Show more`}
|
: "Show more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -428,10 +428,15 @@ export default function SplashScreen({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "idle" || !onDismiss) return;
|
if (mode !== "idle" || !onDismiss) return;
|
||||||
function handler() { triggerExit(onDismiss); }
|
function handler() { triggerExit(onDismiss); }
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
// Delay registering listeners by one frame so the event that triggered
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
const t = setTimeout(() => {
|
||||||
|
window.addEventListener("keydown", handler, { once: true });
|
||||||
|
window.addEventListener("mousedown", handler, { once: true });
|
||||||
|
window.addEventListener("touchstart", handler, { once: true });
|
||||||
|
}, 200);
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
window.removeEventListener("keydown", handler);
|
window.removeEventListener("keydown", handler);
|
||||||
window.removeEventListener("mousedown", handler);
|
window.removeEventListener("mousedown", handler);
|
||||||
window.removeEventListener("touchstart", handler);
|
window.removeEventListener("touchstart", handler);
|
||||||
|
|||||||
@@ -175,6 +175,24 @@
|
|||||||
border: 1px solid var(--accent-muted);
|
border: 1px solid var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unreadBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const MangaCard = memo(function MangaCard({
|
|||||||
{!!manga.downloadCount && (
|
{!!manga.downloadCount && (
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||||
)}
|
)}
|
||||||
|
{!!manga.unreadCount && (
|
||||||
|
<span className={s.unreadBadge}>{manga.unreadCount}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={s.title}>{manga.title}</p>
|
<p className={s.title}>{manga.title}</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +81,16 @@ export default function Library() {
|
|||||||
const addFolder = useStore((state) => state.addFolder);
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||||
|
const activeChapter = useStore((state) => state.activeChapter);
|
||||||
|
|
||||||
|
|
||||||
|
const prevChapterRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = prevChapterRef.current !== null;
|
||||||
|
prevChapterRef.current = activeChapter?.id ?? null;
|
||||||
|
if (!wasOpen || activeChapter) return;
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}, [activeChapter]);
|
||||||
|
|
||||||
const loadData = useCallback((showLoading = false) => {
|
const loadData = useCallback((showLoading = false) => {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
|
|||||||
@@ -127,11 +127,13 @@
|
|||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewerStrip {
|
.viewerStrip {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: var(--sp-4) 0;
|
padding: var(--sp-4) 0;
|
||||||
|
overflow-anchor: auto; /* browser preserves scroll pos when nodes are added/removed above */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Images ── */
|
/* ── Images ── */
|
||||||
@@ -141,10 +143,14 @@
|
|||||||
}
|
}
|
||||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
||||||
|
|
||||||
/* Fit modes */
|
/* Fit modes.
|
||||||
|
height: auto on .img is the load-bearing rule: the img element is given
|
||||||
|
height={1000} as a layout hint while the image is loading (prevents reflow).
|
||||||
|
Once the image is fully painted the browser must resolve height from the
|
||||||
|
intrinsic dimensions, not the HTML attribute — `height: auto` enforces that. */
|
||||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
||||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; }
|
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
||||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; }
|
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
.fitOriginal { max-width: none; width: auto; height: auto; }
|
||||||
|
|
||||||
/* Longstrip */
|
/* Longstrip */
|
||||||
|
|||||||
+620
-667
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+436
-217
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
|
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
|
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, Globe,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
@@ -13,18 +13,21 @@ import s from "./Search.module.css";
|
|||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type SearchTab = "keyword" | "tag" | "source";
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
|
type TagMode = "AND" | "OR";
|
||||||
|
|
||||||
interface SourceResult {
|
interface SourceResult {
|
||||||
source: Source;
|
source: Source;
|
||||||
mangas: Manga[];
|
mangas: Manga[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CONCURRENCY = 4;
|
const CONCURRENCY = 4;
|
||||||
const RESULTS_PER_SOURCE = 8;
|
const RESULTS_PER_SOURCE = 8;
|
||||||
|
const TAG_PAGE_SIZE = 48;
|
||||||
|
const MAX_TAG_SOURCES = 10;
|
||||||
|
|
||||||
const COMMON_GENRES = [
|
const COMMON_GENRES = [
|
||||||
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||||
@@ -34,11 +37,11 @@ const COMMON_GENRES = [
|
|||||||
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Concurrent fetch helper ───────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
async function runConcurrent<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
fn: (item: T) => Promise<void>,
|
fn: (item: T) => Promise<void>,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -52,15 +55,22 @@ async function runConcurrent<T>(
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared card ───────────────────────────────────────────────────────────────
|
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||||
|
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||||
|
return tags.every((t) => genres.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared card components ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CoverImg = memo(function CoverImg({
|
const CoverImg = memo(function CoverImg({
|
||||||
src, alt, className,
|
src, alt, className,
|
||||||
}: { src: string; alt: string; className?: string }) {
|
}: { src: string; alt: string; className?: string }) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
return (
|
return (
|
||||||
<img src={src} alt={alt} className={className}
|
<img
|
||||||
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
src={src} alt={alt} className={className}
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -82,8 +92,8 @@ function GridSkeleton({ count = 18 }: { count?: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className={s.tagGrid}>
|
<div className={s.tagGrid}>
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
<div key={i} className={s.skCard}>
|
||||||
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -114,12 +124,10 @@ export default function Search() {
|
|||||||
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
|
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
|
||||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||||
|
|
||||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
const [loadingSources, setLoadingSources] = useState(false);
|
||||||
|
|
||||||
const pendingPrefill = useRef<string>("");
|
const pendingPrefill = useRef<string>("");
|
||||||
|
|
||||||
// Consume searchPrefill → route to keyword tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchPrefill) return;
|
if (!searchPrefill) return;
|
||||||
pendingPrefill.current = searchPrefill;
|
pendingPrefill.current = searchPrefill;
|
||||||
@@ -127,12 +135,13 @@ export default function Search() {
|
|||||||
setSearchPrefill("");
|
setSearchPrefill("");
|
||||||
}, [searchPrefill, setSearchPrefill]);
|
}, [searchPrefill, setSearchPrefill]);
|
||||||
|
|
||||||
// Load sources once, shared across all tabs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingSources(true);
|
setLoadingSources(true);
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
cache.get(
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
CACHE_KEYS.SOURCES,
|
||||||
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
|
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => d.sources.nodes.filter((src) => src.id !== "0")),
|
||||||
|
Infinity,
|
||||||
)
|
)
|
||||||
.then(setAllSources)
|
.then(setAllSources)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -140,8 +149,10 @@ export default function Search() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const availableLangs = useMemo(() =>
|
const availableLangs = useMemo(
|
||||||
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
() => Array.from(new Set<string>(allSources.map((src) => src.lang))).sort(),
|
||||||
|
[allSources],
|
||||||
|
);
|
||||||
const hasMultipleLangs = availableLangs.length > 1;
|
const hasMultipleLangs = availableLangs.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -149,13 +160,22 @@ export default function Search() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Search</h1>
|
<h1 className={s.heading}>Search</h1>
|
||||||
<div className={s.tabs}>
|
<div className={s.tabs}>
|
||||||
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
<button
|
||||||
|
className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("keyword")}
|
||||||
|
>
|
||||||
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||||
</button>
|
</button>
|
||||||
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
<button
|
||||||
|
className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("tag")}
|
||||||
|
>
|
||||||
<Hash size={11} weight="bold" /> Tags
|
<Hash size={11} weight="bold" /> Tags
|
||||||
</button>
|
</button>
|
||||||
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
<button
|
||||||
|
className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("source")}
|
||||||
|
>
|
||||||
<List size={11} weight="bold" /> Sources
|
<List size={11} weight="bold" /> Sources
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,38 +219,41 @@ function KeywordTab({
|
|||||||
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||||
preferredLang, pendingPrefill, onMangaClick,
|
preferredLang, pendingPrefill, onMangaClick,
|
||||||
}: {
|
}: {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
loadingSources: boolean;
|
loadingSources: boolean;
|
||||||
availableLangs: string[];
|
availableLangs: string[];
|
||||||
hasMultipleLangs: boolean;
|
hasMultipleLangs: boolean;
|
||||||
preferredLang: string;
|
preferredLang: string;
|
||||||
pendingPrefill: React.MutableRefObject<string>;
|
pendingPrefill: React.MutableRefObject<string>;
|
||||||
onMangaClick: (m: Manga) => void;
|
onMangaClick: (m: Manga) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [submitted, setSubmitted] = useState("");
|
const [submitted, setSubmitted] = useState("");
|
||||||
const [results, setResults] = useState<SourceResult[]>([]);
|
const [results, setResults] = useState<SourceResult[]>([]);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||||
const [includeNsfw, setIncludeNsfw] = useState(false);
|
const [includeNsfw, setIncludeNsfw] = useState(false);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const allSourcesRef = useRef<Source[]>([]);
|
const allSourcesRef = useRef<Source[]>([]);
|
||||||
const selectedLangsRef = useRef<Set<string>>(new Set());
|
const selectedLangsRef = useRef<Set<string>>(new Set());
|
||||||
|
const includeNsfwRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
||||||
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
||||||
|
useEffect(() => { includeNsfwRef.current = includeNsfw; }, [includeNsfw]);
|
||||||
|
|
||||||
// Set default lang selection once sources load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allSources.length) return;
|
if (!allSources.length) return;
|
||||||
const available = new Set(allSources.map((s) => s.lang));
|
const available = new Set(allSources.map((src) => src.lang));
|
||||||
setSelectedLangs(available.has(preferredLang)
|
setSelectedLangs(
|
||||||
? new Set([preferredLang])
|
available.has(preferredLang)
|
||||||
: new Set(availableLangs.slice(0, 1))
|
? new Set([preferredLang])
|
||||||
|
: new Set(availableLangs.slice(0, 1)),
|
||||||
);
|
);
|
||||||
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allSources]);
|
||||||
|
|
||||||
// Consume prefill once sources are ready
|
// Consume prefill once sources are ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -239,7 +262,7 @@ function KeywordTab({
|
|||||||
const q = pendingPrefill.current;
|
const q = pendingPrefill.current;
|
||||||
pendingPrefill.current = "";
|
pendingPrefill.current = "";
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
doSearch(q);
|
Promise.resolve().then(() => doSearch(q));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loadingSources]);
|
}, [loadingSources]);
|
||||||
|
|
||||||
@@ -248,11 +271,11 @@ function KeywordTab({
|
|||||||
const getVisibleSources = useCallback((): Source[] => {
|
const getVisibleSources = useCallback((): Source[] => {
|
||||||
let filtered = allSourcesRef.current;
|
let filtered = allSourcesRef.current;
|
||||||
if (selectedLangsRef.current.size > 0)
|
if (selectedLangsRef.current.size > 0)
|
||||||
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
|
filtered = filtered.filter((src) => selectedLangsRef.current.has(src.lang));
|
||||||
if (!includeNsfw)
|
if (!includeNsfwRef.current)
|
||||||
filtered = filtered.filter((s) => !s.isNsfw);
|
filtered = filtered.filter((src) => !src.isNsfw);
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [includeNsfw]);
|
}, []);
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
const trimmed = q.trim();
|
const trimmed = q.trim();
|
||||||
@@ -277,12 +300,12 @@ function KeywordTab({
|
|||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
setResults((prev) => prev.map((r) =>
|
setResults((prev) => prev.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
|
||||||
));
|
));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
setResults((prev) => prev.map((r) =>
|
setResults((prev) => prev.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
@@ -299,7 +322,7 @@ function KeywordTab({
|
|||||||
|
|
||||||
const visibleCount = getVisibleSources().length;
|
const visibleCount = getVisibleSources().length;
|
||||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||||
const allDone = results.every((r) => !r.loading);
|
const allDone = results.length > 0 && results.every((r) => !r.loading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -314,6 +337,13 @@ function KeywordTab({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
className={s.clearBtn}
|
||||||
|
onClick={() => { setQuery(""); inputRef.current?.focus(); }}
|
||||||
|
title="Clear"
|
||||||
|
>×</button>
|
||||||
|
)}
|
||||||
{hasMultipleLangs && (
|
{hasMultipleLangs && (
|
||||||
<button
|
<button
|
||||||
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||||
@@ -345,7 +375,8 @@ function KeywordTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.langGrid}>
|
<div className={s.langGrid}>
|
||||||
{availableLangs.map((lang) => (
|
{availableLangs.map((lang) => (
|
||||||
<button key={lang}
|
<button
|
||||||
|
key={lang}
|
||||||
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||||
onClick={() => toggleLang(lang)}
|
onClick={() => toggleLang(lang)}
|
||||||
>
|
>
|
||||||
@@ -366,7 +397,7 @@ function KeywordTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!submitted && (
|
{!submitted ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Search across sources</p>
|
<p className={s.emptyText}>Search across sources</p>
|
||||||
@@ -381,9 +412,7 @@ function KeywordTab({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{submitted && (
|
|
||||||
<div className={s.results}>
|
<div className={s.results}>
|
||||||
{results.length === 0 && (
|
{results.length === 0 && (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
@@ -399,8 +428,9 @@ function KeywordTab({
|
|||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
<span className={s.sourceName}>{source.displayName}</span>
|
||||||
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
||||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
{loading
|
||||||
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
? <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />
|
||||||
|
: mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className={s.sourceError}>{error}</p>
|
<p className={s.sourceError}>{error}</p>
|
||||||
@@ -418,6 +448,7 @@ function KeywordTab({
|
|||||||
{allDone && !hasResults && (
|
{allDone && !hasResults && (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||||
|
<p className={s.emptyHint}>Try a different spelling or fewer words</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -428,147 +459,239 @@ function KeywordTab({
|
|||||||
|
|
||||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
const MANGAS_BY_GENRE = `
|
||||||
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
const TAG_MAX_SOURCES = 12; // max sources to query
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown> {
|
||||||
|
if (tags.length === 0) return {};
|
||||||
|
if (mode === "AND") return { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
|
||||||
|
return { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
|
||||||
|
}
|
||||||
|
|
||||||
function TagTab({
|
function TagTab({
|
||||||
preferredLang, onMangaClick,
|
allSources, loadingSources, preferredLang, onMangaClick,
|
||||||
}: {
|
}: {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
loadingSources: boolean;
|
loadingSources: boolean;
|
||||||
preferredLang: string;
|
preferredLang: string;
|
||||||
onMangaClick: (m: Manga) => void;
|
onMangaClick: (m: Manga) => void;
|
||||||
}) {
|
}) {
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
const [activeTags, setActiveTags] = useState<string[]>([]);
|
||||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
const [tagMode, setTagMode] = useState<TagMode>("AND");
|
||||||
const [loadingTag, setLoadingTag] = useState(false);
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
|
||||||
const [tagFilter, setTagFilter] = useState("");
|
|
||||||
// Track next page to fetch per source for "load more from network"
|
|
||||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
|
||||||
const sourcesRef = useRef<Source[]>([]);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
const [localResults, setLocalResults] = useState<Manga[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||||
|
const [loadingMoreLocal, setLoadingMoreLocal] = useState(false);
|
||||||
|
const [localOffset, setLocalOffset] = useState(0);
|
||||||
|
const [localHasNext, setLocalHasNext] = useState(false);
|
||||||
|
const abortLocalRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
async function drillTag(tag: string) {
|
const [searchSources, setSearchSources] = useState(false);
|
||||||
if (tag === activeTag && !loadingTag) return;
|
const [sourceResults, setSourceResults] = useState<Manga[]>([]);
|
||||||
setActiveTag(tag);
|
const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
|
||||||
setTagResults([]);
|
const [loadingMoreSource, setLoadingMoreSource] = useState(false);
|
||||||
setLoadingTag(true);
|
const srcNextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
setVisibleCount(TAG_PAGE_SIZE);
|
const abortSourceRef = useRef<AbortController | null>(null);
|
||||||
nextPageRef.current = new Map();
|
|
||||||
|
|
||||||
abortRef.current?.abort();
|
useEffect(() => () => {
|
||||||
|
abortLocalRef.current?.abort();
|
||||||
|
abortSourceRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTags.length === 0) {
|
||||||
|
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortLocalRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortLocalRef.current = ctrl;
|
||||||
|
setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false);
|
||||||
|
setLoadingLocal(true);
|
||||||
|
|
||||||
try {
|
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||||
const sources = await cache.get(CACHE_KEYS.SOURCES, () =>
|
MANGAS_BY_GENRE,
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 },
|
||||||
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
|
ctrl.signal,
|
||||||
);
|
).then((d) => {
|
||||||
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
if (ctrl.signal.aborted) return;
|
||||||
sourcesRef.current = deduped;
|
setLocalResults(d.mangas.nodes);
|
||||||
|
setTotalCount(d.mangas.totalCount);
|
||||||
|
setLocalHasNext(d.mangas.pageInfo.hasNextPage);
|
||||||
|
setLocalOffset(TAG_PAGE_SIZE);
|
||||||
|
}).catch((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingLocal(false);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTags, tagMode]);
|
||||||
|
|
||||||
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
useEffect(() => {
|
||||||
for (const src of deduped) {
|
if (!searchSources || activeTags.length === 0 || loadingSources) return;
|
||||||
nextPageRef.current.set(src.id, -1);
|
|
||||||
|
abortSourceRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortSourceRef.current = ctrl;
|
||||||
|
|
||||||
|
setSourceResults([]);
|
||||||
|
srcNextPageRef.current = new Map();
|
||||||
|
setLoadingSourceSearch(true);
|
||||||
|
|
||||||
|
const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES);
|
||||||
|
const primaryTag = activeTags[0];
|
||||||
|
|
||||||
|
for (const src of sources) srcNextPageRef.current.set(src.id, -1);
|
||||||
|
|
||||||
|
runConcurrent(sources, async (src) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", activeTags);
|
||||||
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
|
||||||
|
|
||||||
|
const result = await cache
|
||||||
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
|
pageKey,
|
||||||
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
|
||||||
|
ctrl.signal,
|
||||||
|
).then((d) => d.fetchSourceManga),
|
||||||
|
)
|
||||||
|
.catch((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
ps.add(1);
|
||||||
|
srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -1);
|
||||||
|
|
||||||
|
const matching = activeTags.length > 1
|
||||||
|
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
||||||
|
: result.mangas;
|
||||||
|
|
||||||
|
if (matching.length > 0) {
|
||||||
|
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
|
||||||
|
setLoadingSourceSearch(false);
|
||||||
}
|
}
|
||||||
|
}, ctrl.signal).finally(() => {
|
||||||
|
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
|
||||||
|
});
|
||||||
|
|
||||||
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
return () => { ctrl.abort(); };
|
||||||
await runConcurrent(deduped, async (src) => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
if (ctrl.signal.aborted) return;
|
}, [searchSources, activeTags, allSources, loadingSources]);
|
||||||
const pageResults: Manga[] = [];
|
|
||||||
// Fetch TAG_FETCH_PAGES pages in series per source
|
async function loadMoreLocal() {
|
||||||
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
if (loadingMoreLocal || !localHasNext) return;
|
||||||
if (ctrl.signal.aborted) return;
|
setLoadingMoreLocal(true);
|
||||||
try {
|
abortLocalRef.current?.abort();
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const ctrl = new AbortController();
|
||||||
FETCH_SOURCE_MANGA,
|
abortLocalRef.current = ctrl;
|
||||||
{ source: src.id, type: "SEARCH", page, query: tag },
|
try {
|
||||||
ctrl.signal,
|
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||||
);
|
MANGAS_BY_GENRE,
|
||||||
pageResults.push(...d.fetchSourceManga.mangas);
|
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset },
|
||||||
if (!d.fetchSourceManga.hasNextPage) {
|
ctrl.signal,
|
||||||
nextPageRef.current.set(src.id, -1); // no more pages
|
);
|
||||||
break;
|
if (ctrl.signal.aborted) return;
|
||||||
} else if (page === TAG_FETCH_PAGES) {
|
setLocalResults((prev) => [...prev, ...d.mangas.nodes]);
|
||||||
// Still has more pages beyond what we fetched upfront
|
setLocalHasNext(d.mangas.pageInfo.hasNextPage);
|
||||||
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
setLocalOffset((o) => o + TAG_PAGE_SIZE);
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
break; // source error — move on
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
|
||||||
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) setLoadingTag(false);
|
if (!ctrl.signal.aborted) setLoadingMoreLocal(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
const sourceHasMore = searchSources &&
|
||||||
if (!activeTag || loadingMore) return;
|
[...srcNextPageRef.current.values()].some((p) => p > 0);
|
||||||
|
|
||||||
// First check if we have more buffered results to show
|
async function loadMoreSource() {
|
||||||
if (visibleCount < tagResults.length) {
|
if (loadingMoreSource || !sourceHasMore) return;
|
||||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
setLoadingMoreSource(true);
|
||||||
return;
|
abortSourceRef.current?.abort();
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise fetch next pages from sources
|
|
||||||
const sourcesToFetch = sourcesRef.current.filter(
|
|
||||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
|
||||||
);
|
|
||||||
if (sourcesToFetch.length === 0) return;
|
|
||||||
|
|
||||||
setLoadingMore(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortSourceRef.current = ctrl;
|
||||||
|
|
||||||
|
const sources = dedupeSources(allSources, preferredLang)
|
||||||
|
.slice(0, MAX_TAG_SOURCES)
|
||||||
|
.filter((src) => (srcNextPageRef.current.get(src.id) ?? -1) > 0);
|
||||||
|
const primaryTag = activeTags[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runConcurrent(sourcesToFetch, async (src) => {
|
await runConcurrent(sources, async (src) => {
|
||||||
const page = nextPageRef.current.get(src.id)!;
|
const page = srcNextPageRef.current.get(src.id)!;
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const ps = getPageSet(src.id, "SEARCH", activeTags);
|
||||||
FETCH_SOURCE_MANGA,
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags);
|
||||||
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
|
||||||
ctrl.signal,
|
const result = await cache
|
||||||
);
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
pageKey,
|
||||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
FETCH_SOURCE_MANGA,
|
||||||
}
|
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||||
} catch (e: any) {
|
ctrl.signal,
|
||||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
).then((d) => d.fetchSourceManga),
|
||||||
}
|
)
|
||||||
|
.catch((e: any) => {
|
||||||
|
if (e?.name !== "AbortError") srcNextPageRef.current.set(src.id, -1);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
ps.add(page);
|
||||||
|
srcNextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
|
|
||||||
|
const matching = activeTags.length > 1
|
||||||
|
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
||||||
|
: result.mangas;
|
||||||
|
|
||||||
|
if (matching.length > 0)
|
||||||
|
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) setLoadingMoreSource(false);
|
||||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTag(tag: string) {
|
||||||
|
srcNextPageRef.current = new Map();
|
||||||
|
setSourceResults([]);
|
||||||
|
setActiveTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const filteredGenres = useMemo(() => {
|
const filteredGenres = useMemo(() => {
|
||||||
const q = tagFilter.trim().toLowerCase();
|
const q = tagFilter.trim().toLowerCase();
|
||||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||||
}, [tagFilter]);
|
}, [tagFilter]);
|
||||||
|
|
||||||
const visibleResults = tagResults.slice(0, visibleCount);
|
const hasActiveTags = activeTags.length > 0;
|
||||||
const hasMore = visibleCount < tagResults.length ||
|
const localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]);
|
||||||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
const mergedResults = searchSources
|
||||||
|
? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))]
|
||||||
|
: localResults;
|
||||||
|
const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.splitRoot}>
|
<div className={s.splitRoot}>
|
||||||
@@ -581,15 +704,19 @@ function TagTab({
|
|||||||
value={tagFilter}
|
value={tagFilter}
|
||||||
onChange={(e) => setTagFilter(e.target.value)}
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{tagFilter && (
|
||||||
|
<button className={s.splitSearchClear} onClick={() => setTagFilter("")} title="Clear">×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={s.splitList}>
|
<div className={s.splitList}>
|
||||||
{filteredGenres.map((tag) => (
|
{filteredGenres.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
|
className={[s.splitItem, activeTags.includes(tag) ? s.splitItemActive : ""].join(" ")}
|
||||||
onClick={() => drillTag(tag)}
|
onClick={() => toggleTag(tag)}
|
||||||
>
|
>
|
||||||
{tag}
|
<span className={s.splitItemLabel}>{tag}</span>
|
||||||
|
{activeTags.includes(tag) && <span className={s.tagCheckMark}>✓</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
||||||
@@ -597,42 +724,110 @@ function TagTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.splitContent}>
|
<div className={s.splitContent}>
|
||||||
{!activeTag ? (
|
{!hasActiveTags ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<Hash size={32} weight="light" className={s.emptyIcon} />
|
<Hash size={32} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Browse by tag</p>
|
<p className={s.emptyText}>Browse by tag</p>
|
||||||
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
|
<p className={s.emptyHint}>Select one or more genre tags to find matching manga.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className={s.tagActiveBar}>
|
||||||
|
<div className={s.tagPillRow}>
|
||||||
|
{activeTags.map((tag) => (
|
||||||
|
<span key={tag} className={s.tagPill}>
|
||||||
|
{tag}
|
||||||
|
<button className={s.tagPillRemove} onClick={() => toggleTag(tag)} title={`Remove ${tag}`}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={s.tagBarRight}>
|
||||||
|
{activeTags.length > 1 && (
|
||||||
|
<div className={s.tagModeToggle}>
|
||||||
|
<button
|
||||||
|
className={[s.tagModeBtn, tagMode === "AND" ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => setTagMode("AND")}
|
||||||
|
title="Match ALL tags"
|
||||||
|
>AND</button>
|
||||||
|
<button
|
||||||
|
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => setTagMode("OR")}
|
||||||
|
title="Match ANY tag"
|
||||||
|
>OR</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={[s.tagModeBtn, searchSources ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => setSearchSources((v) => !v)}
|
||||||
|
title="Also search across sources (slower, requires network)"
|
||||||
|
disabled={loadingSources}
|
||||||
|
>
|
||||||
|
<Globe size={11} weight="light" style={{ marginRight: 3, verticalAlign: "middle" }} />
|
||||||
|
Sources
|
||||||
|
</button>
|
||||||
|
<button className={s.tagClearAll} onClick={() => setActiveTags([])}>Clear all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={s.splitContentHeader}>
|
<div className={s.splitContentHeader}>
|
||||||
<span className={s.splitContentTitle}>{activeTag}</span>
|
<span className={s.splitContentTitle}>
|
||||||
{loadingTag
|
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`}
|
||||||
|
{searchSources && (
|
||||||
|
<span style={{ marginLeft: 6, fontWeight: 400, opacity: 0.55, fontSize: "0.9em" }}>
|
||||||
|
+ sources
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{(loadingLocal || loadingSourceSearch)
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
: <span className={s.splitResultCount}>
|
: <span className={s.splitResultCount}>
|
||||||
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
{totalVisible}{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
|
||||||
</span>}
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{loadingTag ? (
|
|
||||||
<GridSkeleton count={50} />
|
{loadingLocal ? (
|
||||||
) : tagResults.length > 0 ? (
|
<GridSkeleton count={48} />
|
||||||
|
) : mergedResults.length > 0 ? (
|
||||||
<div className={s.tagGrid}>
|
<div className={s.tagGrid}>
|
||||||
{visibleResults.map((m) => (
|
{mergedResults.map((m) => (
|
||||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||||
))}
|
))}
|
||||||
{hasMore && (
|
|
||||||
|
{loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={`sk-src-${i}`} className={s.skCard}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(localHasNext || sourceHasMore) && (
|
||||||
<div className={s.showMoreCell}>
|
<div className={s.showMoreCell}>
|
||||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
{localHasNext && (
|
||||||
{loadingMore
|
<button className={s.showMoreBtn} onClick={loadMoreLocal} disabled={loadingMoreLocal}>
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
{loadingMoreLocal
|
||||||
: "Show more"}
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
</button>
|
: "Show more (library)"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sourceHasMore && (
|
||||||
|
<button className={s.showMoreBtn} onClick={loadMoreSource} disabled={loadingMoreSource}>
|
||||||
|
{loadingMoreSource
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
|
: "Show more (sources)"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>No results for "{activeTag}"</p>
|
<p className={s.emptyText}>No results for {activeTags.join(` ${tagMode} `)}</p>
|
||||||
|
<p className={s.emptyHint}>
|
||||||
|
{searchSources
|
||||||
|
? "Try OR mode or broader tags."
|
||||||
|
: "Try OR mode, enable Sources, or check that these manga are in your library."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -647,11 +842,11 @@ function TagTab({
|
|||||||
function SourceTab({
|
function SourceTab({
|
||||||
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
||||||
}: {
|
}: {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
loadingSources: boolean;
|
loadingSources: boolean;
|
||||||
availableLangs: string[];
|
availableLangs: string[];
|
||||||
hasMultipleLangs: boolean;
|
hasMultipleLangs: boolean;
|
||||||
onMangaClick: (m: Manga) => void;
|
onMangaClick: (m: Manga) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedLang, setSelectedLang] = useState<string>("all");
|
const [selectedLang, setSelectedLang] = useState<string>("all");
|
||||||
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
||||||
@@ -659,29 +854,33 @@ function SourceTab({
|
|||||||
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||||
const [browseQuery, setBrowseQuery] = useState("");
|
const [browseQuery, setBrowseQuery] = useState("");
|
||||||
const [submitted, setSubmitted] = useState("");
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const [hasNextPage, setHasNextPage] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
const visibleSources = useMemo(() =>
|
const visibleSources = useMemo(
|
||||||
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
|
() => selectedLang === "all" ? allSources : allSources.filter((src) => src.lang === selectedLang),
|
||||||
[allSources, selectedLang]
|
[allSources, selectedLang],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
|
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
setLoadingBrowse(true);
|
if (page === 1) { setLoadingBrowse(true); setBrowseResults([]); }
|
||||||
setBrowseResults([]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
{ source: src.id, type, page: 1, query: q ?? null },
|
{ source: src.id, type, page, query: q ?? null },
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
);
|
||||||
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
|
if (ctrl.signal.aborted) return;
|
||||||
|
setBrowseResults((prev) => page === 1 ? d.fetchSourceManga.mangas : [...prev, ...d.fetchSourceManga.mangas]);
|
||||||
|
setHasNextPage(d.fetchSourceManga.hasNextPage);
|
||||||
|
setCurrentPage(page);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -759,38 +958,58 @@ function SourceTab({
|
|||||||
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||||
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
{loadingBrowse
|
||||||
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
</div>
|
: browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>
|
||||||
<div className={s.sourceBrowseBar}>
|
}
|
||||||
<div className={s.searchBar} style={{ flex: 1 }}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
className={s.searchInput}
|
|
||||||
placeholder={`Search ${activeSource.displayName}…`}
|
|
||||||
value={browseQuery}
|
|
||||||
onChange={(e) => setBrowseQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
/>
|
|
||||||
{submitted && (
|
|
||||||
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
<div className={s.sourceBrowseBar}>
|
||||||
<div className={s.tagGrid}>
|
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||||
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
|
<input
|
||||||
|
className={s.searchInput}
|
||||||
|
placeholder={`Search ${activeSource.displayName}…`}
|
||||||
|
value={browseQuery}
|
||||||
|
onChange={(e) => setBrowseQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
/>
|
||||||
|
{submitted && (
|
||||||
|
<button className={s.clearBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingBrowse && browseResults.length === 0 ? (
|
||||||
|
<GridSkeleton />
|
||||||
|
) : browseResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={s.tagGrid}>
|
||||||
|
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||||
|
</div>
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className={s.loadMoreRow}>
|
||||||
|
<button
|
||||||
|
className={s.showMoreBtn}
|
||||||
|
onClick={() => activeSource && fetchBrowse(activeSource, submitted ? "SEARCH" : "POPULAR", submitted || undefined, currentPage + 1)}
|
||||||
|
disabled={loadingBrowse}
|
||||||
|
>
|
||||||
|
{loadingBrowse
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
|
: "Load more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !loadingBrowse ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -973,7 +973,7 @@
|
|||||||
}
|
}
|
||||||
/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */
|
/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */
|
||||||
.dlSectionLabel {
|
.dlSectionLabel {
|
||||||
padding: 6px var(--sp-3) 2px;
|
padding: 6px var(--sp-3) 4px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
@@ -987,13 +987,14 @@
|
|||||||
padding: 2px var(--sp-2) var(--sp-2);
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clean pill-style buttons — label + count inline, no column stacking */
|
||||||
.dlNextBtn {
|
.dlNextBtn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
justify-content: center;
|
||||||
padding: 6px 4px;
|
gap: 5px;
|
||||||
|
padding: 5px 6px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
background: var(--bg-overlay);
|
background: var(--bg-overlay);
|
||||||
@@ -1003,17 +1004,25 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dlNextBtn:hover:not(:disabled) {
|
.dlNextBtn:hover:not(:disabled) {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
border-color: var(--accent-dim);
|
border-color: var(--accent-dim);
|
||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
.dlNextBtn:hover:not(:disabled) .dlNextSub {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
.dlNextBtn:disabled { opacity: 0.3; cursor: default; }
|
.dlNextBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
/* The "(n new)" count badge — sits inline as a dimmed suffix */
|
||||||
.dlNextSub {
|
.dlNextSub {
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color var(--t-fast), opacity var(--t-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dlDivider {
|
.dlDivider {
|
||||||
@@ -1022,13 +1031,34 @@
|
|||||||
margin: var(--sp-1) var(--sp-2);
|
margin: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Range row: swaps in at the same height as dlItem — no layout shift */
|
||||||
.dlRangeRow {
|
.dlRangeRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 2px var(--sp-2) var(--sp-2);
|
padding: 7px var(--sp-2) 7px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dlRangeBack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.dlRangeBack:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
|
||||||
.dlRangeInput {
|
.dlRangeInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -131,18 +131,21 @@ function DownloadDropdown({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
|
{!showRange ? (
|
||||||
<span>Custom range…</span>
|
<button className={s.dlItem} onClick={() => setShowRange(true)}>
|
||||||
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
<span>Custom range…</span>
|
||||||
</button>
|
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
||||||
{showRange && (
|
</button>
|
||||||
|
) : (
|
||||||
<div className={s.dlRangeRow}>
|
<div className={s.dlRangeRow}>
|
||||||
|
<button className={s.dlRangeBack} onClick={() => setShowRange(false)} title="Back">‹</button>
|
||||||
<input
|
<input
|
||||||
className={s.dlRangeInput}
|
className={s.dlRangeInput}
|
||||||
placeholder="From"
|
placeholder="From"
|
||||||
value={rangeFrom}
|
value={rangeFrom}
|
||||||
onChange={(e) => setRangeFrom(e.target.value)}
|
onChange={(e) => setRangeFrom(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<span className={s.dlRangeSep}>–</span>
|
<span className={s.dlRangeSep}>–</span>
|
||||||
<input
|
<input
|
||||||
@@ -157,7 +160,7 @@ function DownloadDropdown({
|
|||||||
disabled={!rangeFrom.trim() || !rangeTo.trim()}
|
disabled={!rangeFrom.trim() || !rangeTo.trim()}
|
||||||
onClick={enqueueRange}
|
onClick={enqueueRange}
|
||||||
>
|
>
|
||||||
Queue
|
Go
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -298,6 +301,7 @@ export default function SeriesDetail() {
|
|||||||
const activeManga = useStore((state) => state.activeManga);
|
const activeManga = useStore((state) => state.activeManga);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const openReader = useStore((state) => state.openReader);
|
const openReader = useStore((state) => state.openReader);
|
||||||
|
const activeChapter = useStore((state) => state.activeChapter);
|
||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const updateSettings = useStore((state) => state.updateSettings);
|
const updateSettings = useStore((state) => state.updateSettings);
|
||||||
const addToast = useStore((state) => state.addToast);
|
const addToast = useStore((state) => state.addToast);
|
||||||
@@ -512,6 +516,13 @@ export default function SeriesDetail() {
|
|||||||
});
|
});
|
||||||
}, [applyChapters]);
|
}, [applyChapters]);
|
||||||
|
|
||||||
|
// Reload chapters whenever the reader is closed so read/unread state is always current.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeChapter || !activeManga) return;
|
||||||
|
reloadChapters(activeManga.id);
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}, [activeChapter, activeManga, reloadChapters]);
|
||||||
|
|
||||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
|
|||||||
description="Automatically open the next chapter at the end of a long strip"
|
description="Automatically open the next chapter at the end of a long strip"
|
||||||
checked={settings.autoNextChapter ?? false}
|
checked={settings.autoNextChapter ?? false}
|
||||||
onChange={(v) => update({ autoNextChapter: v })} />
|
onChange={(v) => update({ autoNextChapter: v })} />
|
||||||
|
{!(settings.autoNextChapter ?? false) && (
|
||||||
|
<Toggle label="Mark read when skipping to next chapter"
|
||||||
|
description="When auto-advance is off, mark the current chapter as read if you tap the next chapter button before finishing it"
|
||||||
|
checked={settings.markReadOnNext ?? true}
|
||||||
|
onChange={(v) => update({ markReadOnNext: v })} />
|
||||||
|
)}
|
||||||
<Stepper label="Pages to preload"
|
<Stepper label="Pages to preload"
|
||||||
description="Images loaded ahead of the current page"
|
description="Images loaded ahead of the current page"
|
||||||
value={settings.preloadPages} min={0} max={10}
|
value={settings.preloadPages} min={0} max={10}
|
||||||
@@ -459,11 +465,16 @@ interface StorageInfo {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StorageBar({ used, limit, total }: { used: number; limit: number | null; total: number }) {
|
function StorageBar({ used, free, limit, total }: { used: number; free: number; limit: number | null; total: number }) {
|
||||||
const cap = limit ?? total;
|
// "Available space" = what's actually usable: already-used manga bytes + free bytes on disk.
|
||||||
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
// We intentionally do NOT use total_bytes (full drive size) as the cap — other apps / OS
|
||||||
const critical = pctUsed > 90;
|
// overhead eat into that, and it makes our bar look almost empty even when downloads are large.
|
||||||
const warning = pctUsed > 75;
|
const available = used + free; // usable space relevant to downloads
|
||||||
|
const cap = limit !== null ? Math.min(limit, available) : available;
|
||||||
|
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
||||||
|
const critical = pctUsed > 90;
|
||||||
|
const warning = pctUsed > 75;
|
||||||
|
const freeInCap = Math.max(0, cap - used);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.storageBarWrap}>
|
<div className={s.storageBarWrap}>
|
||||||
@@ -475,10 +486,12 @@ function StorageBar({ used, limit, total }: { used: number; limit: number | null
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.storageBarLabels}>
|
<div className={s.storageBarLabels}>
|
||||||
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
|
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
|
||||||
<span className={s.storageBarFree}>{fmtBytes(Math.max(0, cap - used))} free</span>
|
<span className={s.storageBarFree}>{fmtBytes(freeInCap)} free</span>
|
||||||
</div>
|
</div>
|
||||||
{limit !== null && total > 0 && (
|
{limit !== null && (
|
||||||
<p className={s.storageBarNote}>Limit {fmtBytes(limit)} of {fmtBytes(total)} total</p>
|
<p className={s.storageBarNote}>
|
||||||
|
Limit {fmtBytes(limit)} · {fmtBytes(free)} free on disk of {fmtBytes(total)} total
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -537,7 +550,7 @@ function StorageTab({ settings, update }: { settings: Settings; update: (p: Part
|
|||||||
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
|
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
|
||||||
{!loading && !error && info && (
|
{!loading && !error && info && (
|
||||||
<>
|
<>
|
||||||
<StorageBar used={mangaBytes} limit={limitBytes} total={totalBytes} />
|
<StorageBar used={mangaBytes} free={freeBytes} limit={limitBytes} total={totalBytes} />
|
||||||
<div className={s.storageLegend}>
|
<div className={s.storageLegend}>
|
||||||
<div className={s.storageLegendRow}>
|
<div className={s.storageLegendRow}>
|
||||||
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
|
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
|
||||||
@@ -587,8 +600,8 @@ function StorageTab({ settings, update }: { settings: Settings; update: (p: Part
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (
|
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > (freeBytes + mangaBytes) && (
|
||||||
<p className={s.storageLimitHint}>Limit exceeds available free space ({fmtBytes(freeBytes)})</p>
|
<p className={s.storageLimitHint}>Limit exceeds available space ({fmtBytes(freeBytes)} free on disk)</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+126
-22
@@ -1,37 +1,73 @@
|
|||||||
/**
|
/**
|
||||||
* Session-level request cache.
|
* Session-level request cache.
|
||||||
*
|
*
|
||||||
* Key design decisions:
|
* Key design decisions (v1, preserved):
|
||||||
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||||
* - On real errors the entry is evicted so the next call retries.
|
* - On real errors the entry is evicted so the next call retries.
|
||||||
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
||||||
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
||||||
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
||||||
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
||||||
|
*
|
||||||
|
* v2 additions:
|
||||||
|
* - TTL-aware get(): stale entries are re-fetched automatically (default 5 min).
|
||||||
|
* Pass Infinity to pin an entry for the session (source list, extension list).
|
||||||
|
* - getPageSet(): lightweight page-number tracker for multi-page browse sessions.
|
||||||
|
* Mirrors Suwayomi's CACHE_PAGES_KEY pattern so GenreDrillPage / Search TagTab
|
||||||
|
* can resume a session without re-fetching pages already in memory.
|
||||||
|
* - Stable multi-tag cache keys: tag arrays are sorted before joining so
|
||||||
|
* ["Action","Romance"] and ["Romance","Action"] share the same bucket.
|
||||||
*/
|
*/
|
||||||
const store = new Map<string, Promise<unknown>>();
|
|
||||||
|
interface Entry<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
fetchedAt: number; // ms since epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Entry<unknown>>();
|
||||||
const subs = new Map<string, Set<() => void>>();
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
|
||||||
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
export const cache = {
|
export const cache = {
|
||||||
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
/**
|
||||||
if (!store.has(key)) {
|
* Return a cached promise.
|
||||||
store.set(key, fetcher().catch((err) => {
|
* Re-fetches automatically once the entry is older than `ttl` ms.
|
||||||
// Only evict on real failures, not user cancellations
|
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
|
||||||
if (err?.name !== "AbortError") store.delete(key);
|
*/
|
||||||
return Promise.reject(err);
|
get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
|
||||||
}));
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
}
|
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
||||||
return store.get(key) as Promise<T>;
|
|
||||||
|
const promise = fetcher().catch((err) => {
|
||||||
|
// Only evict on real failures, not user cancellations
|
||||||
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}) as Promise<T>;
|
||||||
|
|
||||||
|
store.set(key, { promise, fetchedAt: Date.now() });
|
||||||
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
has(key: string): boolean { return store.has(key); },
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
|
/** How old (ms) a cached entry is, or undefined if absent. */
|
||||||
|
ageOf(key: string): number | undefined {
|
||||||
|
const e = store.get(key);
|
||||||
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
clear(key: string) {
|
clear(key: string) {
|
||||||
store.delete(key);
|
store.delete(key);
|
||||||
subs.get(key)?.forEach((cb) => cb());
|
subs.get(key)?.forEach((cb) => cb());
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
store.clear();
|
store.clear();
|
||||||
subs.forEach((set) => set.forEach((cb) => cb()));
|
subs.forEach((set) => set.forEach((cb) => cb()));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
||||||
subscribe(key: string, cb: () => void): () => void {
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
if (!subs.has(key)) subs.set(key, new Set());
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
@@ -40,7 +76,8 @@ export const cache = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Cache key constants — single source of truth, prevents mismatches ─────────
|
// ── Cache key constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
@@ -48,15 +85,45 @@ export const CACHE_KEYS = {
|
|||||||
GENRE: (genre: string) => `genre:${genre}`,
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
MANGA: (id: number) => `manga:${id}`,
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable key for a browse session's page-number set.
|
||||||
|
* Tag arrays are sorted so order never creates duplicate buckets —
|
||||||
|
* ["Action","Romance"] and ["Romance","Action"] share one key.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
|
||||||
|
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
|
||||||
|
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
|
||||||
|
*/
|
||||||
|
sourceMangaPages(
|
||||||
|
sourceId: string,
|
||||||
|
type: "POPULAR" | "LATEST" | "SEARCH",
|
||||||
|
query?: string | string[],
|
||||||
|
): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `pages:${sourceId}:${type}:${q}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Per-page result key. Always pair with sourceMangaPages(). */
|
||||||
|
sourceMangaPage(
|
||||||
|
sourceId: string,
|
||||||
|
type: "POPULAR" | "LATEST" | "SEARCH",
|
||||||
|
page: number,
|
||||||
|
query?: string | string[],
|
||||||
|
): string {
|
||||||
|
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||||
|
return `page:${sourceId}:${type}:${page}:${q}`;
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ── In-flight request deduplication (for non-cached calls) ────────────────────
|
// ── In-flight request deduplication (for non-cached calls) ───────────────────
|
||||||
//
|
//
|
||||||
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
||||||
// cache but still get fired multiple times when a user rapidly opens/closes a
|
// cache but still get fired multiple times when a user rapidly opens/closes a
|
||||||
// manga. This map deduplicates them so only one network round-trip is active at
|
// manga. This map deduplicates them so only one network round-trip is active at
|
||||||
// a time per key — regardless of how many components request it simultaneously.
|
// a time per key.
|
||||||
//
|
|
||||||
const inflight = new Map<string, Promise<unknown>>();
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
@@ -66,18 +133,56 @@ export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source frecency helpers ────────────────────────────────────────────────────
|
// ── PageSet: per-session page-number tracker ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
|
||||||
|
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
|
||||||
|
// a browse session is actively paginating.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
|
||||||
|
// ps.add(1); // after fetching page 1
|
||||||
|
// ps.next(); // → 2
|
||||||
|
// ps.pages(); // → Set {1}
|
||||||
|
// ps.clear(); // call when query/tags change
|
||||||
|
|
||||||
const FRECENCY_KEY = "moku-source-frecency";
|
const _pageSets = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
export interface PageSet {
|
||||||
|
add(page: number): void;
|
||||||
|
pages(): Set<number>;
|
||||||
|
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
|
||||||
|
next(): number;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageSet(
|
||||||
|
sourceId: string,
|
||||||
|
type: "POPULAR" | "LATEST" | "SEARCH",
|
||||||
|
query?: string | string[],
|
||||||
|
): PageSet {
|
||||||
|
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
||||||
|
return {
|
||||||
|
add(page) {
|
||||||
|
if (!_pageSets.has(key)) _pageSets.set(key, new Set());
|
||||||
|
_pageSets.get(key)!.add(page);
|
||||||
|
},
|
||||||
|
pages() { return new Set(_pageSets.get(key) ?? []); },
|
||||||
|
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
||||||
|
clear() { _pageSets.delete(key); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source frecency helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FRECENCY_KEY = "moku-source-frecency";
|
||||||
const MAX_FRECENCY_SOURCES = 4;
|
const MAX_FRECENCY_SOURCES = 4;
|
||||||
|
|
||||||
type FrecencyMap = Record<string, number>;
|
type FrecencyMap = Record<string, number>;
|
||||||
|
|
||||||
function loadFrecency(): FrecencyMap {
|
function loadFrecency(): FrecencyMap {
|
||||||
try {
|
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
||||||
const raw = localStorage.getItem(FRECENCY_KEY);
|
catch { return {}; }
|
||||||
return raw ? JSON.parse(raw) : {};
|
|
||||||
} catch { return {}; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFrecency(map: FrecencyMap) {
|
function saveFrecency(map: FrecencyMap) {
|
||||||
@@ -95,7 +200,6 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
|||||||
const map = loadFrecency();
|
const map = loadFrecency();
|
||||||
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
||||||
const hasFrecency = withScore.some((x) => x.score > 0);
|
const hasFrecency = withScore.some((x) => x.score > 0);
|
||||||
|
|
||||||
if (hasFrecency) {
|
if (hasFrecency) {
|
||||||
return withScore
|
return withScore
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
|
|||||||
+8
-1
@@ -76,6 +76,12 @@ export interface Settings {
|
|||||||
splashCards?: boolean;
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
/**
|
||||||
|
* Mark a chapter as read when the user manually taps the "next chapter"
|
||||||
|
* button/key while autoNextChapter is off. Has no effect when autoNextChapter
|
||||||
|
* is on (the scroll-based mark-as-read logic handles that path).
|
||||||
|
*/
|
||||||
|
markReadOnNext: boolean;
|
||||||
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||||
readerDebounceMs: number;
|
readerDebounceMs: number;
|
||||||
/** UI colour theme. Applied as data-theme on <html>. */
|
/** UI colour theme. Applied as data-theme on <html>. */
|
||||||
@@ -92,7 +98,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
offsetDoubleSpreads: false,
|
offsetDoubleSpreads: false,
|
||||||
preloadPages: 3,
|
preloadPages: 3,
|
||||||
autoMarkRead: true,
|
autoMarkRead: true,
|
||||||
autoNextChapter: false,
|
autoNextChapter: true,
|
||||||
libraryCropCovers: true,
|
libraryCropCovers: true,
|
||||||
libraryPageSize: 48,
|
libraryPageSize: 48,
|
||||||
showNsfw: false,
|
showNsfw: false,
|
||||||
@@ -110,6 +116,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
splashCards: true,
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
|
markReadOnNext: true,
|
||||||
readerDebounceMs: 120,
|
readerDebounceMs: 120,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user