Compare commits

..

32 Commits

Author SHA1 Message Date
Youwes09 6a8b6e7f93 Fix: Inject Bundle Resources Via --config Flag 2026-03-20 21:34:42 -05:00
Youwes09 a452cdc2e3 Fix: Branch Input to Windows Workflow 2026-03-20 21:25:23 -05:00
Youwes09 71a6eb02b5 Fix: Use jq in Workflow 2026-03-20 21:22:52 -05:00
Youwes09 50e981574a Add: Windows Build Workflow 2026-03-20 21:16:11 -05:00
Youwes09 bf38e00cf3 [V1] Fixed Mark as Read Refresh + Auto Feature 2026-03-04 00:00:12 -06:00
Youwes09 eb7360ee05 [V1] Rebased Reader to 9a0afed + Improvements 2026-02-28 18:30:00 -06:00
Youwes09 c9eba3da86 [V1] Fixed Bad State Issue on Reader (WIP) 2026-02-27 22:18:38 -06:00
Youwes09 fc68d3ac7e New Patch for Reader 2026-02-27 17:49:07 -06:00
Youwes09 1fa1c3a2e0 [V1] Search Overhaul + Tag Fixes 2026-02-26 23:55:39 -06:00
Youwes09 8c38330143 [V1] Reader Simplification & Fixes 2026-02-26 23:31:01 -06:00
Youwes09 272d7673ce [V1] Fix NixOS Build 2026-02-26 21:05:24 -06:00
Youwes09 3d074a1fb1 [V1] Attempt on Reader Optimization + Infinite Scroll Glitches 2026-02-26 19:49:48 -06:00
Youwes09 be15cb6ad8 [V1] Patched Tauri Capabilities Permissions 2026-02-25 21:56:05 -06:00
Youwes09 3aee69939b [V1] Forgot to add Binaries prefix 2026-02-25 21:52:57 -06:00
Youwes09 0557f3f2d6 [V1] Fixed Tauri Sidecar Capabilities 2026-02-25 21:51:31 -06:00
Youwes09 817af0d10a [V1] Changed Windows Auto-Detect Binary 2026-02-25 21:40:52 -06:00
Youwes09 70afb08f83 [V1] Updated Search on Workflow 2026-02-25 21:06:43 -06:00
Youwes09 f751f34c68 [V1] Updated Hashing 2026-02-25 21:01:33 -06:00
Youwes09 8c9d3fc783 [V1] Updated SHA Checker 2026-02-25 20:59:19 -06:00
Youwes09 0f0cd87e6d [V1] Windows Workflow 2026-02-25 20:53:20 -06:00
Youwes09 f5a1b13e43 [V1] Attempt to fix Apple Cert Signing 2026-02-25 20:30:06 -06:00
Youwes09 4fca379715 [V1] Fixed Tauri Cert Signing 2026-02-25 20:21:05 -06:00
Youwes09 ac5e3ae53b [V1] Requires Bundle Patch (MacOS) 2026-02-25 20:11:27 -06:00
Youwes09 6d39d5574a [V1] Removed Tauri-MacOS Patch 2026-02-25 20:06:08 -06:00
Youwes09 5e8f0d2f52 [V1] Fix Suwayomi Detection in Workflow 2026-02-25 20:02:11 -06:00
Youwes09 87e2009d4e [V1] MacOS-Patch & Fixes (WIP) 2026-02-25 19:58:37 -06:00
Youwes09 2f5103c48c [V1] Patched Tauri-Targets & Removed Bun Detection 2026-02-25 19:53:12 -06:00
Shozikan 9d9c1b61e7 [V1] Changed to MacOS-Latest & Tauri-Refactor 2026-02-25 19:49:14 -06:00
Shozikan a1a0f360d7 [V1] Fixed ENV & Download Link 2026-02-25 19:43:47 -06:00
Youwes09 9a0afed2b0 [V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility 2026-02-25 19:41:14 -06:00
Youwes09 28e9e3bcf8 [V1] Redid Series Detail Download Layout 2026-02-24 22:02:53 -06:00
Youwes09 ac04c39ead [V1] Prepared for v0.3.0 Release 2026-02-24 20:18:45 -06:00
30 changed files with 3158 additions and 1589 deletions
-66
View File
@@ -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
+248
View File
@@ -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
+143
View File
@@ -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
+2 -2
View File
@@ -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')
+45 -30
View File
@@ -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)
+45
View File
@@ -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}"
+20 -91
View File
@@ -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,16 +55,7 @@ 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 \
@@ -83,15 +68,13 @@ if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
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)
@@ -105,7 +88,6 @@ PYEOF
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 \ nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
flatpak-builder \ flatpak-builder \
--repo="${REPO_ROOT}/repo" \ --repo="${REPO_ROOT}/repo" \
@@ -118,67 +100,14 @@ PYEOF
"${REPO_ROOT}/moku.flatpak" \ "${REPO_ROOT}/moku.flatpak" \
dev.moku.app dev.moku.app
# Clean up intermediate build artefacts — keep only moku.flatpak
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo" rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
success "moku.flatpak created" 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
View File
@@ -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
+58 -2
View File
@@ -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.
+136 -33
View File
@@ -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",
+2 -2
View File
@@ -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]
+4 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+8 -2
View File
@@ -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/>}
+50 -78
View File
@@ -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); }
+130 -52
View File
@@ -2,18 +2,50 @@ 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[],
@@ -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);
@@ -66,8 +103,9 @@ export default function GenreDrillPage() {
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);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre }, { source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal, ctrl.signal,
); ).then((d) => d.fetchSourceManga),
pageItems.push(...d.fetchSourceManga.mangas); )
if (!d.fetchSourceManga.hasNextPage) { .catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) break;
ps.add(page);
// For multi-tag searches: client-side AND filter for tags beyond the first.
// Sources only support a single query string, so we send primaryTag and
// drop results that don't contain the remaining tags in their genre array.
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) {
nextPageRef.current.set(src.id, -1); nextPageRef.current.set(src.id, -1);
break; break;
} else if (page === INITIAL_PAGES) { } else if (page === INITIAL_PAGES) {
// Has more pages beyond what we fetched upfront — mark for "load more"
nextPageRef.current.set(src.id, INITIAL_PAGES + 1); nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
} }
} catch (e: any) {
if (e?.name === "AbortError") return;
nextPageRef.current.set(src.id, -1);
break;
}
} }
if (!ctrl.signal.aborted && pageItems.length > 0) { 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,17 +204,20 @@ 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 libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id)); const libIds = new Set(libMatches.map((m) => m.id));
const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcAll]); return dedupeMangaById([...libMatches, ...srcOnly]);
}, [libraryManga, sourceManga, genre]); }, [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;
@@ -163,16 +225,14 @@ export default function GenreDrillPage() {
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);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre }, { source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal, ctrl.signal,
); ).then((d) => d.fetchSourceManga),
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); )
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) .catch((e: any) => {
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
} catch (e: any) {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); 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) => (
@@ -291,7 +369,7 @@ export default function GenreDrillPage() {
<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>
)} )}
+5
View File
@@ -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); }
// Delay registering listeners by one frame so the event that triggered
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true }); window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true }); window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", 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);
+18
View File
@@ -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);
+13
View File
@@ -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);
+9 -3
View File
@@ -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 */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+389 -170
View File
@@ -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,6 +13,7 @@ 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;
@@ -25,6 +26,8 @@ interface SourceResult {
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,7 +37,7 @@ 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[],
@@ -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>
))} ))}
@@ -116,10 +126,8 @@ export default function Search() {
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>
@@ -218,19 +238,22 @@ function KeywordTab({
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(
available.has(preferredLang)
? new Set([preferredLang]) ? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1)) : 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,137 +459,226 @@ 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 [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
const [tagFilter, setTagFilter] = useState(""); 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(() => () => {
const ctrl = new AbortController(); abortLocalRef.current?.abort();
abortRef.current = ctrl; abortSourceRef.current?.abort();
}, []);
try { useEffect(() => {
const sources = await cache.get(CACHE_KEYS.SOURCES, () => if (activeTags.length === 0) {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
.then((d) => d.sources.nodes.filter((s) => s.id !== "0")) return;
);
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
sourcesRef.current = deduped;
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
for (const src of deduped) {
nextPageRef.current.set(src.id, -1);
} }
abortLocalRef.current?.abort();
const ctrl = new AbortController();
abortLocalRef.current = ctrl;
setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false);
setLoadingLocal(true);
// Stream results in: fetch each source's pages concurrently, update state as each settles gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
await runConcurrent(deduped, async (src) => { MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const pageResults: Manga[] = []; setLocalResults(d.mangas.nodes);
// Fetch TAG_FETCH_PAGES pages in series per source setTotalCount(d.mangas.totalCount);
for (let page = 1; page <= TAG_FETCH_PAGES; page++) { 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]);
useEffect(() => {
if (!searchSources || activeTags.length === 0 || loadingSources) return;
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; if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( 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, FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: tag }, { 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);
});
return () => { ctrl.abort(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchSources, activeTags, allSources, loadingSources]);
async function loadMoreLocal() {
if (loadingMoreLocal || !localHasNext) return;
setLoadingMoreLocal(true);
abortLocalRef.current?.abort();
const ctrl = new AbortController();
abortLocalRef.current = ctrl;
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset },
ctrl.signal, ctrl.signal,
); );
pageResults.push(...d.fetchSourceManga.mangas); if (ctrl.signal.aborted) return;
if (!d.fetchSourceManga.hasNextPage) { setLocalResults((prev) => [...prev, ...d.mangas.nodes]);
nextPageRef.current.set(src.id, -1); // no more pages setLocalHasNext(d.mangas.pageInfo.hasNextPage);
break; setLocalOffset((o) => o + TAG_PAGE_SIZE);
} else if (page === TAG_FETCH_PAGES) {
// Still has more pages beyond what we fetched upfront
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
}
} 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);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: activeTag }, { source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal, ctrl.signal,
); ).then((d) => d.fetchSourceManga),
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); )
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) { .catch((e: any) => {
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); if (e?.name !== "AbortError") srcNextPageRef.current.set(src.id, -1);
} return null;
} catch (e: any) { });
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
} 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(() => {
@@ -566,9 +686,12 @@ function TagTab({
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}>
{loadingMoreLocal
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</> ? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more"} : "Show more (library)"}
</button> </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>
)} )}
</> </>
@@ -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,9 +958,13 @@ 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)" }} />
: browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>
}
</div> </div>
</div>
<div className={s.sourceBrowseBar}> <div className={s.sourceBrowseBar}>
<div className={s.searchBar} style={{ flex: 1 }}> <div className={s.searchBar} style={{ flex: 1 }}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" /> <MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
@@ -773,24 +976,40 @@ function SourceTab({
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/> />
{submitted && ( {submitted && (
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button> <button className={s.clearBtn} onClick={clearSearch} title="Clear search">×</button>
)} )}
</div> </div>
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}> <button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
Search Search
</button> </button>
</div> </div>
</div>
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? ( {loadingBrowse && browseResults.length === 0 ? (
<GridSkeleton />
) : browseResults.length > 0 ? (
<>
<div className={s.tagGrid}> <div className={s.tagGrid}>
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)} {browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
</div> </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>
+35 -5
View File
@@ -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;
+14 -3
View File
@@ -131,18 +131,21 @@ function DownloadDropdown({
</> </>
)} )}
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}> {!showRange ? (
<button className={s.dlItem} onClick={() => setShowRange(true)}>
<span>Custom range</span> <span>Custom range</span>
<span className={s.dlItemSub}>Enter chapter numbers</span> <span className={s.dlItemSub}>Enter chapter numbers</span>
</button> </button>
{showRange && ( ) : (
<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));
+21 -8
View File
@@ -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.
// We intentionally do NOT use total_bytes (full drive size) as the cap — other apps / OS
// overhead eat into that, and it makes our bar look almost empty even when downloads are large.
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 pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const critical = pctUsed > 90; const critical = pctUsed > 90;
const warning = pctUsed > 75; 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>
+122 -18
View File
@@ -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.
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
*/
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;
const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations // Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
})); }) as Promise<T>;
}
return store.get(key) 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,7 +133,47 @@ 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 _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 FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4; const MAX_FRECENCY_SOURCES = 4;
@@ -74,10 +181,8 @@ 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
View File
@@ -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",
}; };