[V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility

This commit is contained in:
Youwes09
2026-02-25 19:41:14 -06:00
parent 28e9e3bcf8
commit 9a0afed2b0
14 changed files with 1333 additions and 462 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
+216
View File
@@ -0,0 +1,216 @@
name: Build macOS
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.3.0)"
required: true
env:
SUWAYOMI_VERSION: "2.1.1867"
jobs:
# ── Build frontend once, share via artifact ────────────────────────────────
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
# ── Per-arch Tauri builds ──────────────────────────────────────────────────
tauri:
name: Tauri (${{ matrix.target }})
needs: frontend
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-14
suwayomi_asset: "Suwayomi-Server-v${{ env.SUWAYOMI_VERSION }}-macOS-arm64.tar.gz"
suwayomi_sha256: "c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c"
- target: x86_64-apple-darwin
runner: macos-13
suwayomi_asset: "Suwayomi-Server-v${{ env.SUWAYOMI_VERSION }}-macOS-x64.tar.gz"
suwayomi_sha256: "c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174"
runs-on: ${{ matrix.runner }}
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: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: pnpm/action-setup@v4
with:
version: latest
# ── Download & verify Suwayomi ────────────────────────────────────────
- name: Download Suwayomi (${{ matrix.target }})
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v${{ env.SUWAYOMI_VERSION }}/${{ matrix.suwayomi_asset }}" \
-o suwayomi.tar.gz
echo "${{ matrix.suwayomi_sha256 }} suwayomi.tar.gz" | shasum -a 256 -c -
mkdir -p suwayomi-extracted
tar -xzf suwayomi.tar.gz -C suwayomi-extracted --strip-components=1
- name: Stage Suwayomi sidecar
run: |
mkdir -p src-tauri/binaries
# The v2.1.1867 native macOS tarball ships a launcher called
# "Suwayomi-Server" at the top level alongside its bundled JDK.
LAUNCHER=$(find suwayomi-extracted -maxdepth 1 -type f -name "Suwayomi-Server" | head -1)
# Fallback: first top-level executable that isn't a .jar
if [ -z "$LAUNCHER" ]; then
LAUNCHER=$(find suwayomi-extracted -maxdepth 1 -type f -perm +111 \
! -name "*.jar" ! -name "*.dylib" | head -1)
fi
if [ -z "$LAUNCHER" ]; then
echo "ERROR: could not find Suwayomi launcher in tarball"
ls -lR suwayomi-extracted
exit 1
fi
echo "Using launcher: $LAUNCHER"
# Tauri sidecar naming: <stem>-<target-triple>
cp "$LAUNCHER" "src-tauri/binaries/suwayomi-server-${{ matrix.target }}"
chmod +x "src-tauri/binaries/suwayomi-server-${{ matrix.target }}"
# Copy the full bundle so the launcher can find its JDK + JAR
# via relative paths at runtime inside the .app Resources dir.
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
# ── Build Tauri .app + .dmg ───────────────────────────────────────────
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Code-signing — set these repo secrets for a signed/notarised build.
# Leave unset for unsigned (Gatekeeper warns on first open, fine for testing).
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
args: --target ${{ matrix.target }}
- name: Upload arch .dmg
uses: actions/upload-artifact@v4
with:
name: moku-${{ matrix.target }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 7
- name: Upload .app bundle (for universal job)
uses: actions/upload-artifact@v4
with:
name: app-${{ matrix.target }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/macos/
retention-days: 1
# ── Universal binary ──────────────────────────────────────────────────────
universal:
name: Universal .dmg
needs: tauri
runs-on: macos-14
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")
echo "arm64: $ARM_APP"
echo "x64: $X64_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
+22 -25
View File
@@ -1,37 +1,19 @@
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
- Fix Storage Glitch (Currently uses Full Space Instead of Free Space)
- Clean up Migrate Model to be more initutive
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
- -
@@ -50,11 +32,13 @@ Testing:
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip 6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc. 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 1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail) - Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
20. Expand History (Total Time Read, etc) - Properly Kill Tachidesk-Server
12. Delete all Downloads should also cancel all download queues - Fix Reader Marking As Read.
13. Cancel Download along with Queue & Download Timeout Feature - 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)
Completed: Completed:
@@ -74,6 +58,19 @@ 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
+135 -32
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"
@@ -1800,9 +1819,9 @@ name = "moku"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"nix",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-shell", "tauri-plugin-shell",
@@ -1866,24 +1885,21 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
@@ -2624,6 +2640,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3242,6 +3278,20 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows 0.57.0",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3289,7 +3339,7 @@ dependencies = [
"tao-macros", "tao-macros",
"unicode-segmentation", "unicode-segmentation",
"url", "url",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
@@ -3360,7 +3410,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"window-vibrancy", "window-vibrancy",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -3486,7 +3536,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -3512,7 +3562,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"wry", "wry",
] ]
@@ -4241,10 +4291,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
] ]
[[package]] [[package]]
@@ -4265,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@@ -4315,6 +4365,16 @@ dependencies = [
"windows-version", "windows-version",
] ]
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@@ -4337,14 +4397,26 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.2",
@@ -4356,8 +4428,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.2.1", "windows-link 0.2.1",
"windows-result 0.4.1", "windows-result 0.4.1",
"windows-strings 0.5.1", "windows-strings 0.5.1",
@@ -4374,6 +4446,17 @@ dependencies = [
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@@ -4385,6 +4468,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@@ -4418,6 +4512,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4921,7 +5024,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows", "windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
+1 -1
View File
@@ -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]
+177 -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,159 @@ 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 mut 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")
}
}
/// 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 .app's
/// Resources directory (macOS) or alongside the binary (other platforms).
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
) -> Result<std::ffi::OsString, String> {
if !binary.trim().is_empty() {
return Ok(std::ffi::OsString::from(binary));
}
let resource_dir = app
.path()
.resource_dir()
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
// Tauri places sidecars as <stem>-<target-triple> in the resource dir.
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(p.into_os_string());
}
}
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,16 +246,30 @@ 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 bin = resolve_server_binary(&binary, &app)?;
let shell = app.shell(); let shell = app.shell();
match shell.command(&binary).spawn() { match shell
.command(&bin)
// Tell Suwayomi where to put its data (rootDir flag).
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args([&format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
)])
.spawn()
{
Ok((_rx, child)) => { Ok((_rx, child)) => {
println!("Spawned server: {}", binary); println!("Spawned server: {:?}", 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 {:?}: {}", bin, e);
Err(e.to_string()) Err(e.to_string())
} }
} }
+17 -4
View File
@@ -26,18 +26,31 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["appimage"], "targets": ["appimage", "dmg", "macos"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"externalBin": [
"binaries/suwayomi-server"
],
"resources": {
"binaries/suwayomi-bundle": "suwayomi-bundle"
},
"macOS": {
"minimumSystemVersion": "11.0",
"exceptionDomain": "localhost"
}
}, },
"plugins": { "plugins": {
"shell": { "shell": {
"open": true "open": true,
"sidecar": [
"binaries/suwayomi-server"
]
} }
} }
} }
+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); }
+146 -68
View File
@@ -2,22 +2,54 @@ import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store"; import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
import s from "./GenreDrillPage.module.css"; import s from "./GenreDrillPage.module.css";
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50; // how many items to show at once const PAGE_SIZE = 50;
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source const INITIAL_PAGES = 3;
const MAX_SOURCES = 12; // max sources to query concurrently const MAX_SOURCES = 12;
const CONCURRENCY = 4; // parallel source fetches const CONCURRENCY = 4;
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
* multi-tag string ("Action+Romance"). Parse it into an array.
*
* Callers set multi-tag filters via:
* setGenreFilter("Action+Romance")
*
* The Explore feed's "See all" button continues to pass single strings and
* requires no change.
*/
function parseTags(genreFilter: string): string[] {
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
}
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
/**
* Client-side AND filter.
* Sources only accept a single query string, so we send the first tag and
* drop results that don't also have the remaining tags in their genre list.
*/
function matchesAllTags(m: Manga, tags: string[]): boolean {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
}
async function runConcurrent<T>( async function runConcurrent<T>(
items: T[], items: T[],
fn: (item: T) => Promise<void>, fn: (item: T) => Promise<void>,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
let i = 0; let i = 0;
@@ -46,7 +78,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string;
// ── GenreDrillPage ──────────────────────────────────────────────────────────── // ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() { export default function GenreDrillPage() {
const genre = useStore((st) => st.genreFilter); const genreFilter = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter); const setGenreFilter = useStore((st) => st.setGenreFilter);
const setPreviewManga = useStore((st) => st.setPreviewManga); const setPreviewManga = useStore((st) => st.setPreviewManga);
const settings = useStore((st) => st.settings); const settings = useStore((st) => st.settings);
@@ -54,6 +86,11 @@ export default function GenreDrillPage() {
const addFolder = useStore((st) => st.addFolder); const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
// Parse the filter string into individual tags
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
// First tag is sent as the source query string (sources accept only one term)
const primaryTag = tags[0] ?? "";
const [libraryManga, setLibraryManga] = useState<Manga[]>([]); const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]); const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true); const [loadingInitial, setLoadingInitial] = useState(true);
@@ -62,12 +99,13 @@ export default function GenreDrillPage() {
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
// Per-source next-page tracker; -1 means exhausted // Per-source next-page tracker; -1 means exhausted
const nextPageRef = useRef<Map<string, number>>(new Map()); const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]); const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!genre) return; if (tags.length === 0) return;
abortRef.current?.abort(); abortRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -81,7 +119,7 @@ export default function GenreDrillPage() {
const preferredLang = settings.preferredExtensionLang || "en"; const preferredLang = settings.preferredExtensionLang || "en";
// ── Library (fire-and-forget, doesn't block skeleton removal) ───────── // ── Library (local DB, instant) ───────────────────────────────────────
cache.get(CACHE_KEYS.LIBRARY, () => cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([ Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
@@ -94,46 +132,67 @@ export default function GenreDrillPage() {
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); }) .then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.catch((e) => { if (e?.name !== "AbortError") console.error(e); }); .catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources: stream results in as each source responds ──────────────── // ── Sources: stream results as each source responds ───────────────────
// Source list is stable within a session — cache indefinitely.
cache.get(CACHE_KEYS.SOURCES, () => cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)) .then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => { ).then(async (allSources) => {
const sources = allSources.slice(0, MAX_SOURCES); const sources = allSources.slice(0, MAX_SOURCES);
sourcesRef.current = sources; sourcesRef.current = sources;
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
for (const src of sources) nextPageRef.current.set(src.id, -1); for (const src of sources) nextPageRef.current.set(src.id, -1);
await runConcurrent(sources, async (src) => { await runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
// On navigation-away → back the pages are still in the TTL store, so fetchPage
// returns the cached promise immediately without hitting the network.
const ps = getPageSet(src.id, "SEARCH", tags);
const pageItems: Manga[] = []; const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) { for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
FETCH_SOURCE_MANGA, const result = await cache
{ source: src.id, type: "SEARCH", page, query: genre }, .get<{ mangas: Manga[]; hasNextPage: boolean }>(
ctrl.signal, pageKey,
); () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
pageItems.push(...d.fetchSourceManga.mangas); FETCH_SOURCE_MANGA,
if (!d.fetchSourceManga.hasNextPage) { { source: src.id, type: "SEARCH", page, query: primaryTag },
nextPageRef.current.set(src.id, -1); ctrl.signal,
break; ).then((d) => d.fetchSourceManga),
} else if (page === INITIAL_PAGES) { )
// Has more pages beyond what we fetched upfront — mark for "load more" .catch((e: any) => {
nextPageRef.current.set(src.id, INITIAL_PAGES + 1); if (e?.name !== "AbortError") console.error(e);
} return null;
} catch (e: any) { });
if (e?.name === "AbortError") return;
if (!result || ctrl.signal.aborted) break;
ps.add(page);
// For multi-tag searches: client-side AND filter for tags beyond the first.
// Sources only support a single query string, so we send primaryTag and
// drop results that don't contain the remaining tags in their genre array.
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) {
nextPageRef.current.set(src.id, -1); nextPageRef.current.set(src.id, -1);
break; break;
} else if (page === INITIAL_PAGES) {
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
} }
} }
if (!ctrl.signal.aborted && pageItems.length > 0) { if (!ctrl.signal.aborted && pageItems.length > 0) {
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
// legitimate different-source results that share a common title (e.g. "Action" genre)
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems])); setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
// Drop the skeleton as soon as we have anything
setLoadingInitial(false); setLoadingInitial(false);
} }
}, ctrl.signal); }, ctrl.signal);
@@ -145,34 +204,35 @@ export default function GenreDrillPage() {
}); });
return () => { ctrl.abort(); }; return () => { ctrl.abort(); };
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps // genreFilter (not tags) as the dep — tags is derived from it and would
// cause an extra render on every parse; genreFilter is the stable identity.
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ─────────────────────────────────────────────────── // ── Derived merged list ───────────────────────────────────────────────────
const filtered = useMemo(() => { const filtered = useMemo(() => {
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); // For multi-tag: library results must match ALL tags
const libIds = new Set(libMatches.map((m) => m.id)); const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...srcAll]); const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
}, [libraryManga, sourceManga, genre]); return dedupeMangaById([...libMatches, ...srcOnly]);
}, [libraryManga, sourceManga, tags]);
// ── Load more ───────────────────────────────────────────────────────────── // ── Load more ─────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length; const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork; const hasMore = hasMoreVisible || hasMoreNetwork;
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (loadingMore) return; if (loadingMore) return;
// If there are buffered results, just reveal the next page // Fast path: buffered results already in memory
if (hasMoreVisible) { if (hasMoreVisible) {
setVisibleCount((v) => v + PAGE_SIZE); setVisibleCount((v) => v + PAGE_SIZE);
return; return;
} }
// Fetch next pages from network // Slow path: fetch next pages from sources
const sources = sourcesRef.current.filter( const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
);
if (!sources.length) return; if (!sources.length) return;
setLoadingMore(true); setLoadingMore(true);
@@ -184,18 +244,35 @@ export default function GenreDrillPage() {
await runConcurrent(sources, async (src) => { await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!; const page = nextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const ps = getPageSet(src.id, "SEARCH", tags);
FETCH_SOURCE_MANGA, const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal, const result = await cache
); .get<{ mangas: Manga[]; hasNextPage: boolean }>(
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); pageKey,
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); FETCH_SOURCE_MANGA,
} catch (e: any) { { source: src.id, type: "SEARCH", page, query: primaryTag },
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); ctrl.signal,
} ).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
if (matching.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal); }, ctrl.signal);
} finally { } finally {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
@@ -203,7 +280,7 @@ export default function GenreDrillPage() {
setLoadingMore(false); setLoadingMore(false);
} }
} }
}, [loadingMore, hasMoreVisible, genre]); }, [loadingMore, hasMoreVisible, primaryTag, tags]);
// ── Context menu ────────────────────────────────────────────────────────── // ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: React.MouseEvent, m: Manga) { function openCtx(e: React.MouseEvent, m: Manga) {
@@ -245,6 +322,7 @@ export default function GenreDrillPage() {
} }
const visibleItems = filtered.slice(0, visibleCount); const visibleItems = filtered.slice(0, visibleCount);
const label = tagsLabel(tags);
return ( return (
<div className={s.root}> <div className={s.root}>
@@ -253,7 +331,7 @@ export default function GenreDrillPage() {
<ArrowLeft size={13} weight="light" /> <ArrowLeft size={13} weight="light" />
<span>Back</span> <span>Back</span>
</button> </button>
<span className={s.title}>{genre}</span> <span className={s.title}>{label}</span>
{loadingInitial && filtered.length === 0 ? null : ( {loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}> <span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
@@ -274,7 +352,7 @@ export default function GenreDrillPage() {
))} ))}
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{genre}".</div> <div className={s.empty}>No manga found for "{label}".</div>
) : ( ) : (
<div className={s.grid}> <div className={s.grid}>
{visibleItems.map((m) => ( {visibleItems.map((m) => (
@@ -290,8 +368,8 @@ export default function GenreDrillPage() {
<div className={s.showMoreCell}> <div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}> <button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore {loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading</> ? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading</>
: `Show more`} : "Show more"}
</button> </button>
</div> </div>
)} )}
+8 -3
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); }
window.addEventListener("keydown", handler, { once: true }); // Delay registering listeners by one frame so the event that triggered
window.addEventListener("mousedown", handler, { once: true }); // idle (mousemove/mousedown) doesn't immediately dismiss the splash.
window.addEventListener("touchstart", handler, { once: true }); const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
}, 200);
return () => { return () => {
clearTimeout(t);
window.removeEventListener("keydown", handler); window.removeEventListener("keydown", handler);
window.removeEventListener("mousedown", handler); window.removeEventListener("mousedown", handler);
window.removeEventListener("touchstart", handler); window.removeEventListener("touchstart", handler);
+69
View File
@@ -339,4 +339,73 @@
letter-spacing: var(--tracking-wide); padding: 1px 5px; letter-spacing: var(--tracking-wide); padding: 1px 5px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); flex-shrink: 0; color: var(--text-faint); flex-shrink: 0;
}
/* ── Multi-tag bar ───────────────────────────────────────────────────────────── */
.tagActiveBar {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-wrap: wrap;
background: var(--bg-raised);
min-height: 40px;
}
.tagPillRow {
display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0;
}
.tagPill {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 3px 6px 3px 8px; border-radius: var(--radius-sm);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.tagPillRemove {
display: flex; align-items: center; justify-content: center;
width: 14px; height: 14px; border-radius: 50%;
background: none; border: none; cursor: pointer;
color: var(--accent-fg); font-size: 13px; line-height: 1;
opacity: 0.7; padding: 0; flex-shrink: 0;
transition: opacity var(--t-fast);
}
.tagPillRemove:hover { opacity: 1; }
.tagBarRight {
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
}
/* AND / OR toggle */
.tagModeToggle {
display: flex; align-items: center;
background: var(--bg-overlay); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 1px; gap: 1px;
}
.tagModeBtn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); padding: 3px 8px;
border-radius: calc(var(--radius-sm) - 1px);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: background var(--t-fast), color var(--t-fast);
}
.tagModeBtn:hover { color: var(--text-muted); }
.tagModeBtnActive {
background: var(--accent-muted); color: var(--accent-fg);
}
.tagClearAll {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 3px 0;
transition: color var(--t-fast);
}
.tagClearAll:hover { color: var(--text-muted); }
/* Checkmark on active tag sidebar items */
.tagCheckMark {
font-size: 10px; margin-left: auto; padding-left: var(--sp-1);
color: var(--accent-fg); flex-shrink: 0;
} }
+358 -147
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,18 +13,21 @@ import s from "./Search.module.css";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
type SearchTab = "keyword" | "tag" | "source"; type SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR";
interface SourceResult { interface SourceResult {
source: Source; source: Source;
mangas: Manga[]; mangas: Manga[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
const CONCURRENCY = 4; const CONCURRENCY = 4;
const RESULTS_PER_SOURCE = 8; const RESULTS_PER_SOURCE = 8;
const TAG_PAGE_SIZE = 48;
const MAX_TAG_SOURCES = 10; // sources queried when "Search sources" is toggled on
const COMMON_GENRES = [ const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance", "Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -34,11 +37,11 @@ const COMMON_GENRES = [
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi", "Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
]; ];
// ── Concurrent fetch helper ─────────────────────────────────────────────────── // ── Shared helpers ────────────────────────────────────────────────────────────
async function runConcurrent<T>( async function runConcurrent<T>(
items: T[], items: T[],
fn: (item: T) => Promise<void>, fn: (item: T) => Promise<void>,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
let i = 0; let i = 0;
@@ -52,7 +55,13 @@ 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 ─────────────────────────────────────────────────────────────── /** Keep only manga whose genre array includes every tag (case-insensitive). */
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,
@@ -114,7 +123,7 @@ export default function Search() {
const setSearchPrefill = useStore((st) => st.setSearchPrefill); const setSearchPrefill = useStore((st) => st.setSearchPrefill);
const setPreviewManga = useStore((st) => st.setPreviewManga); const setPreviewManga = useStore((st) => st.setPreviewManga);
const [allSources, setAllSources] = useState<Source[]>([]); const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false); const [loadingSources, setLoadingSources] = useState(false);
const pendingPrefill = useRef<string>(""); const pendingPrefill = useRef<string>("");
@@ -132,7 +141,8 @@ export default function Search() {
setLoadingSources(true); setLoadingSources(true);
cache.get(CACHE_KEYS.SOURCES, () => cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((src) => src.id !== "0")) .then((d) => d.sources.nodes.filter((src) => src.id !== "0")),
Infinity, // source list is stable within a session
) )
.then(setAllSources) .then(setAllSources)
.catch(console.error) .catch(console.error)
@@ -194,25 +204,26 @@ export default function Search() {
} }
// ── Keyword tab ─────────────────────────────────────────────────────────────── // ── Keyword tab ───────────────────────────────────────────────────────────────
// Unchanged from v1.
function KeywordTab({ function KeywordTab({
allSources, loadingSources, availableLangs, hasMultipleLangs, allSources, loadingSources, availableLangs, hasMultipleLangs,
preferredLang, pendingPrefill, onMangaClick, preferredLang, pendingPrefill, onMangaClick,
}: { }: {
allSources: Source[]; allSources: Source[];
loadingSources: boolean; loadingSources: boolean;
availableLangs: string[]; availableLangs: string[];
hasMultipleLangs: boolean; hasMultipleLangs: boolean;
preferredLang: string; preferredLang: string;
pendingPrefill: React.MutableRefObject<string>; pendingPrefill: React.MutableRefObject<string>;
onMangaClick: (m: Manga) => void; onMangaClick: (m: Manga) => void;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState(""); const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]); const [results, setResults] = useState<SourceResult[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set()); const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
const [includeNsfw, setIncludeNsfw] = useState(false); const [includeNsfw, setIncludeNsfw] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -427,151 +438,273 @@ function KeywordTab({
} }
// ── Tag tab ─────────────────────────────────────────────────────────────────── // ── Tag tab ───────────────────────────────────────────────────────────────────
//
// Two data sources, selectable independently:
//
// 1. Local DB (always on) — instant MangaFilterInput query with AND/OR support.
// "Show more" uses GraphQL offset pagination.
//
// 2. Source search (opt-in via "Search sources" toggle) — fires FETCH_SOURCE_MANGA
// across the top sources, using getPageSet() + cache.get(sourceMangaPage) so
// results survive navigation and "Show more" fetches the next cached page before
// hitting the network.
// For multi-tag AND: sends the first tag as the source query string (sources only
// support one term) and client-filters the results by the remaining tags.
const TAG_PAGE_SIZE = 50; // items shown per "page" const MANGAS_BY_GENRE = `
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
const TAG_MAX_SOURCES = 12; // max sources to query mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown> {
if (tags.length === 0) return {};
if (mode === "AND") return { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
return { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
}
function TagTab({ function TagTab({
preferredLang, onMangaClick, allSources,
loadingSources,
preferredLang,
onMangaClick,
}: { }: {
allSources: Source[]; allSources: Source[];
loadingSources: boolean; loadingSources: boolean;
preferredLang: string; preferredLang: string;
onMangaClick: (m: Manga) => void; onMangaClick: (m: Manga) => void;
}) { }) {
const [activeTag, setActiveTag] = useState<string | null>(null); const [activeTags, setActiveTags] = useState<string[]>([]);
const [tagResults, setTagResults] = useState<Manga[]>([]); const [tagMode, setTagMode] = useState<TagMode>("AND");
const [loadingTag, setLoadingTag] = useState(false); const [tagFilter, setTagFilter] = useState("");
const [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
const [tagFilter, setTagFilter] = useState("");
// Track next page to fetch per source for "load more from network"
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => { abortRef.current?.abort(); }, []); // ── Local DB state ────────────────────────────────────────────────────────
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) { // ── Source search state ───────────────────────────────────────────────────
if (tag === activeTag && !loadingTag) return; const [searchSources, setSearchSources] = useState(false);
setActiveTag(tag); const [sourceResults, setSourceResults] = useState<Manga[]>([]);
setTagResults([]); const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
setLoadingTag(true); const [loadingMoreSource, setLoadingMoreSource] = useState(false);
setVisibleCount(TAG_PAGE_SIZE); // Per-source next-page tracker; -1 = exhausted
nextPageRef.current = new Map(); const srcNextPageRef = useRef<Map<string, number>>(new Map());
const abortSourceRef = useRef<AbortController | null>(null);
abortRef.current?.abort(); useEffect(() => () => {
abortLocalRef.current?.abort();
abortSourceRef.current?.abort();
}, []);
// ── Local DB query ────────────────────────────────────────────────────────
useEffect(() => {
if (activeTags.length === 0) {
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
return;
}
abortLocalRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortRef.current = ctrl; abortLocalRef.current = ctrl;
setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false);
setLoadingLocal(true);
try { gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
const sources = await cache.get(CACHE_KEYS.SOURCES, () => MANGAS_BY_GENRE,
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 },
.then((d) => d.sources.nodes.filter((s) => s.id !== "0")) ctrl.signal,
); ).then((d) => {
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES); if (ctrl.signal.aborted) return;
sourcesRef.current = deduped; setLocalResults(d.mangas.nodes);
setTotalCount(d.mangas.totalCount);
setLocalHasNext(d.mangas.pageInfo.hasNextPage);
setLocalOffset(TAG_PAGE_SIZE);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
if (!ctrl.signal.aborted) setLoadingLocal(false);
});
}, [activeTags, tagMode]); // eslint-disable-line react-hooks/exhaustive-deps
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true // ── Source search ─────────────────────────────────────────────────────────
for (const src of deduped) { // Fires when toggled on (or when tags change while already on).
nextPageRef.current.set(src.id, -1); // Uses getPageSet() + cache.get(sourceMangaPage) so the first page of each
// source is re-used from cache if the user navigates away and back.
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]; // sources only support a single query string
for (const src of sources) srcNextPageRef.current.set(src.id, -1);
runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(1);
srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -1);
// Multi-tag AND: client-filter for tags beyond the first
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); // reveal as results arrive
} }
}, ctrl.signal).finally(() => {
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
});
// Stream results in: fetch each source's pages concurrently, update state as each settles return () => { ctrl.abort(); };
await runConcurrent(deduped, async (src) => { }, [searchSources, activeTags, allSources, loadingSources]); // eslint-disable-line react-hooks/exhaustive-deps
if (ctrl.signal.aborted) return;
const pageResults: Manga[] = []; // ── Load more: local ──────────────────────────────────────────────────────
// Fetch TAG_FETCH_PAGES pages in series per source async function loadMoreLocal() {
for (let page = 1; page <= TAG_FETCH_PAGES; page++) { if (loadingMoreLocal || !localHasNext) return;
if (ctrl.signal.aborted) return; setLoadingMoreLocal(true);
try { abortLocalRef.current?.abort();
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const ctrl = new AbortController();
FETCH_SOURCE_MANGA, abortLocalRef.current = ctrl;
{ source: src.id, type: "SEARCH", page, query: tag }, try {
ctrl.signal, const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
); MANGAS_BY_GENRE,
pageResults.push(...d.fetchSourceManga.mangas); { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset },
if (!d.fetchSourceManga.hasNextPage) { ctrl.signal,
nextPageRef.current.set(src.id, -1); // no more pages );
break; if (ctrl.signal.aborted) return;
} else if (page === TAG_FETCH_PAGES) { setLocalResults((prev) => [...prev, ...d.mangas.nodes]);
// Still has more pages beyond what we fetched upfront setLocalHasNext(d.mangas.pageInfo.hasNextPage);
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1); setLocalOffset((o) => o + TAG_PAGE_SIZE);
}
} catch (e: any) {
if (e?.name === "AbortError") return;
break; // source error — move on
}
}
if (!ctrl.signal.aborted && pageResults.length > 0) {
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
}
}, ctrl.signal);
} catch (e: any) { } catch (e: any) {
if (e?.name !== "AbortError") console.error(e); if (e?.name !== "AbortError") console.error(e);
} finally { } finally {
if (!ctrl.signal.aborted) setLoadingTag(false); if (!ctrl.signal.aborted) setLoadingMoreLocal(false);
} }
} }
async function loadMore() { // ── Load more: sources ────────────────────────────────────────────────────
if (!activeTag || loadingMore) return; const sourceHasMore = searchSources &&
[...srcNextPageRef.current.values()].some((p) => p > 0);
// First check if we have more buffered results to show async function loadMoreSource() {
if (visibleCount < tagResults.length) { if (loadingMoreSource || !sourceHasMore) return;
setVisibleCount((v) => v + TAG_PAGE_SIZE); setLoadingMoreSource(true);
return; abortSourceRef.current?.abort();
}
// Otherwise fetch next pages from sources
const sourcesToFetch = sourcesRef.current.filter(
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
);
if (sourcesToFetch.length === 0) return;
setLoadingMore(true);
abortRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortRef.current = ctrl; abortSourceRef.current = ctrl;
const sources = dedupeSources(allSources, preferredLang)
.slice(0, MAX_TAG_SOURCES)
.filter((src) => (srcNextPageRef.current.get(src.id) ?? -1) > 0);
const primaryTag = activeTags[0];
try { try {
await runConcurrent(sourcesToFetch, async (src) => { await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!; const page = srcNextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const ps = getPageSet(src.id, "SEARCH", activeTags);
FETCH_SOURCE_MANGA, const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags);
{ source: src.id, type: "SEARCH", page, query: activeTag },
ctrl.signal, const result = await cache
); .get<{ mangas: Manga[]; hasNextPage: boolean }>(
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); pageKey,
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) { () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); FETCH_SOURCE_MANGA,
} { source: src.id, type: "SEARCH", page, query: primaryTag },
} catch (e: any) { ctrl.signal,
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); ).then((d) => d.fetchSourceManga),
} )
.catch((e: any) => {
if (e?.name !== "AbortError") srcNextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
srcNextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas;
if (matching.length > 0)
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal); }, ctrl.signal);
} finally { } finally {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) setLoadingMoreSource(false);
setVisibleCount((v) => v + TAG_PAGE_SIZE);
setLoadingMore(false);
}
} }
} }
// ── Tag toggle ────────────────────────────────────────────────────────────
function toggleTag(tag: string) {
// Clear source sessions when tags change — new query = new page buckets
srcNextPageRef.current = new Map();
setSourceResults([]);
setActiveTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}
const filteredGenres = useMemo(() => { const filteredGenres = useMemo(() => {
const q = tagFilter.trim().toLowerCase(); const q = tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
}, [tagFilter]); }, [tagFilter]);
const visibleResults = tagResults.slice(0, visibleCount); const hasActiveTags = activeTags.length > 0;
const hasMore = visibleCount < tagResults.length ||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); // Merge local + source results (local first, source de-duped against local IDs)
const localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]);
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}>
{/* ── Sidebar ────────────────────────────────────────────────────── */}
<div className={s.splitSidebar}> <div className={s.splitSidebar}>
<div className={s.splitSearchWrap}> <div className={s.splitSearchWrap}>
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" /> <MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
@@ -586,53 +719,130 @@ function TagTab({
{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>}
</div> </div>
</div> </div>
{/* ── Content ────────────────────────────────────────────────────── */}
<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>
) : ( ) : (
<> <>
{/* Active tag pills + controls */}
<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="Show manga matching ALL selected tags"
>AND</button>
<button
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setTagMode("OR")}
title="Show manga matching ANY selected tag"
>OR</button>
</div>
)}
{/* "Search sources" toggle — fetches from external sources */}
<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>
{/* Result header */}
<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}
</span>} {localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
</span>
}
</div> </div>
{loadingTag ? (
<GridSkeleton count={50} /> {/* Results grid */}
) : tagResults.length > 0 ? ( {loadingLocal ? (
<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 && (
{/* Inline skeletons while source results are still streaming in */}
{loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => (
<div key={`sk-src-${i}`} className={s.skCard} style={{ width: "auto" }}>
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
{/* Show more buttons — one per data source */}
{(localHasNext || sourceHasMore) && (
<div className={s.showMoreCell}> <div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}> {localHasNext && (
{loadingMore <button className={s.showMoreBtn} onClick={loadMoreLocal} disabled={loadingMoreLocal}>
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</> {loadingMoreLocal
: "Show more"} ? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
</button> : "Show more (library)"}
</button>
)}
{sourceHasMore && (
<button className={s.showMoreBtn} onClick={loadMoreSource} disabled={loadingMoreSource}>
{loadingMoreSource
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more (sources)"}
</button>
)}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className={s.empty}> <div className={s.empty}>
<p className={s.emptyText}>No results for "{activeTag}"</p> <p className={s.emptyText}>No results for {activeTags.join(` ${tagMode} `)}</p>
<p className={s.emptyHint}>
{searchSources
? "Try OR mode or broader tags."
: "Try OR mode, enable Sources, or check that these manga are in your library."}
</p>
</div> </div>
)} )}
</> </>
@@ -643,15 +853,16 @@ function TagTab({
} }
// ── Source tab ──────────────────────────────────────────────────────────────── // ── Source tab ────────────────────────────────────────────────────────────────
// Unchanged from v1.
function SourceTab({ function SourceTab({
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick, allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
}: { }: {
allSources: Source[]; allSources: Source[];
loadingSources: boolean; loadingSources: boolean;
availableLangs: string[]; availableLangs: string[];
hasMultipleLangs: boolean; hasMultipleLangs: boolean;
onMangaClick: (m: Manga) => void; onMangaClick: (m: Manga) => void;
}) { }) {
const [selectedLang, setSelectedLang] = useState<string>("all"); const [selectedLang, setSelectedLang] = useState<string>("all");
const [activeSource, setActiveSource] = useState<Source | null>(null); const [activeSource, setActiveSource] = useState<Source | null>(null);
+126 -22
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.
// Only evict on real failures, not user cancellations * Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
if (err?.name !== "AbortError") store.delete(key); */
return Promise.reject(err); get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
})); const existing = store.get(key) as Entry<T> | undefined;
} if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
return store.get(key) as Promise<T>;
const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
return promise;
}, },
has(key: string): boolean { return store.has(key); }, has(key: string): boolean { return store.has(key); },
/** How old (ms) a cached entry is, or undefined if absent. */
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
clear(key: string) { clear(key: string) {
store.delete(key); store.delete(key);
subs.get(key)?.forEach((cb) => cb()); subs.get(key)?.forEach((cb) => cb());
}, },
clearAll() { clearAll() {
store.clear(); store.clear();
subs.forEach((set) => set.forEach((cb) => cb())); subs.forEach((set) => set.forEach((cb) => cb()));
}, },
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */ /** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
subscribe(key: string, cb: () => void): () => void { subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set()); if (!subs.has(key)) subs.set(key, new Set());
@@ -40,7 +76,8 @@ export const cache = {
}, },
}; };
// ── Cache key constants — single source of truth, prevents mismatches ───────── // ── Cache key constants ───────────────────────────────────────────────────────
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
SOURCES: "sources", SOURCES: "sources",
@@ -48,15 +85,45 @@ export const CACHE_KEYS = {
GENRE: (genre: string) => `genre:${genre}`, GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`, MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`, CHAPTERS: (id: number) => `chapters:${id}`,
/**
* Stable key for a browse session's page-number set.
* Tag arrays are sorted so order never creates duplicate buckets —
* ["Action","Romance"] and ["Romance","Action"] share one key.
*
* Examples:
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
*/
sourceMangaPages(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
/** Per-page result key. Always pair with sourceMangaPages(). */
sourceMangaPage(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
page: number,
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const; } as const;
// ── In-flight request deduplication (for non-cached calls) ─────────────────── // ── In-flight request deduplication (for non-cached calls) ───────────────────
// //
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived // Some requests (chapter lists, manga detail) are NOT stored in the long-lived
// cache but still get fired multiple times when a user rapidly opens/closes a // cache but still get fired multiple times when a user rapidly opens/closes a
// manga. This map deduplicates them so only one network round-trip is active at // manga. This map deduplicates them so only one network round-trip is active at
// a time per key — regardless of how many components request it simultaneously. // a time per key.
//
const inflight = new Map<string, Promise<unknown>>(); const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> { export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
@@ -66,18 +133,56 @@ export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
return p; return p;
} }
// ── Source frecency helpers ──────────────────────────────────────────────────── // ── PageSet: per-session page-number tracker ──────────────────────────────────
//
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
// a browse session is actively paginating.
//
// Usage:
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
// ps.add(1); // after fetching page 1
// ps.next(); // → 2
// ps.pages(); // → Set {1}
// ps.clear(); // call when query/tags change
const FRECENCY_KEY = "moku-source-frecency"; const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
next(): number;
clear(): void;
}
export function getPageSet(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) {
if (!_pageSets.has(key)) _pageSets.set(key, new Set());
_pageSets.get(key)!.add(page);
},
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
// ── Source frecency helpers ───────────────────────────────────────────────────
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4; const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>; type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap { function loadFrecency(): FrecencyMap {
try { try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
const raw = localStorage.getItem(FRECENCY_KEY); catch { return {}; }
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
} }
function saveFrecency(map: FrecencyMap) { function saveFrecency(map: FrecencyMap) {
@@ -95,7 +200,6 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency(); const map = loadFrecency();
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 })); const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
const hasFrecency = withScore.some((x) => x.score > 0); const hasFrecency = withScore.some((x) => x.score > 0);
if (hasFrecency) { if (hasFrecency) {
return withScore return withScore
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)