From 9a0afed2b071d0df790ef202d2a444f2aa256802 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Wed, 25 Feb 2026 19:41:14 -0600 Subject: [PATCH] [V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility --- .github/workflows/build-appimage.yml | 66 --- .github/workflows/build-macos.yml | 216 +++++++++ Todo | 47 +- src-tauri/Cargo.lock | 167 +++++-- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 191 +++++++- src-tauri/tauri.conf.json | 21 +- src/App.tsx | 10 +- src/components/explore/Explore.tsx | 128 +++--- src/components/explore/GenreDrillPage.tsx | 214 ++++++--- src/components/layout/SplashScreen.tsx | 11 +- src/components/pages/Search.module.css | 69 +++ src/components/pages/Search.tsx | 505 +++++++++++++++------- src/lib/cache.ts | 148 ++++++- 14 files changed, 1333 insertions(+), 462 deletions(-) delete mode 100644 .github/workflows/build-appimage.yml create mode 100644 .github/workflows/build-macos.yml diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml deleted file mode 100644 index 7a5e454..0000000 --- a/.github/workflows/build-appimage.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 0000000..da77651 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -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: - + 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 \ No newline at end of file diff --git a/Todo b/Todo index 7314ea6..9813323 100644 --- a/Todo +++ b/Todo @@ -1,37 +1,19 @@ Todo: 3. Explore Manga Upscaler & Other Image Processing 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: -- -- 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 as Package in Nix Flake & Check Later -- GenreDrill & GenreFilter pages do not populate completely. -- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh) -- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy -- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue) -16. Contrast Adjustment Option in Settings for Users (UI FOCUSED) - - -- Fix Mangafire Main Dispatcher Issue - - -- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break) +- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB - 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: - Add PDF Textbook Support -- Major revision to disable entire manga-subsection and use as -solely as a reader/document launcher. -- Multiple Tag Filters + Mor Tags, Types, Etc - Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm) -- Properly Kill Tachidesk-Server - Migration Features - Multi-Page Long Screenshot - @@ -50,11 +32,13 @@ Testing: 6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip 5. Lock reader on valid chapters to avoid bugs, etc. 1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load -- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand - Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail) -20. Expand History (Total Time Read, etc) -12. Delete all Downloads should also cancel all download queues -13. Cancel Download along with Queue & Download Timeout Feature +- Properly Kill Tachidesk-Server +- Fix Reader Marking As Read. +- 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: @@ -74,6 +58,19 @@ Completed: 18. Disable NSFW Extensions option in settings - Filtering by Genre (Accessed by Clicking tags on Manga) - 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 + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db71532..96f7c0a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -285,12 +285,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -396,6 +390,25 @@ dependencies = [ "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]] name = "crossbeam-utils" version = "0.8.21" @@ -645,6 +658,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -1800,9 +1819,9 @@ name = "moku" version = "0.3.0" dependencies = [ "dirs 5.0.1", - "nix", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-shell", @@ -1866,24 +1885,21 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2624,6 +2640,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "redox_syscall" version = "0.5.18" @@ -3242,6 +3278,20 @@ dependencies = [ "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]] name = "system-deps" version = "6.2.2" @@ -3289,7 +3339,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3360,7 +3410,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3486,7 +3536,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3512,7 +3562,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4241,10 +4291,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4265,7 +4315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4315,6 +4365,16 @@ dependencies = [ "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]] name = "windows" version = "0.61.3" @@ -4337,14 +4397,26 @@ dependencies = [ "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]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4356,8 +4428,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4374,6 +4446,17 @@ dependencies = [ "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]] name = "windows-implement" version = "0.60.2" @@ -4385,6 +4468,17 @@ dependencies = [ "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]] name = "windows-interface" version = "0.59.3" @@ -4418,6 +4512,15 @@ dependencies = [ "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]] name = "windows-result" version = "0.3.4" @@ -4921,7 +5024,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e003aaa..48e777b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" walkdir = "2" -nix = { version = "0.29", features = ["fs"] } +sysinfo = "0.32" dirs = "5" [profile.release] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88cef93..5ef740b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; use std::sync::Mutex; -use nix::sys::statvfs::statvfs; +use sysinfo::Disks; use serde::Serialize; use tauri::{Manager, WindowEvent}; 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") .map(PathBuf::from) .unwrap_or_else(|_| { - dirs::home_dir() + dirs::data_dir() .unwrap_or_else(|| PathBuf::from("/")) - .join(".local/share") }); base.join("Tachidesk/downloads") } @@ -49,11 +48,16 @@ fn get_storage_info(downloads_path: String) -> Result { let stat_path = if path.exists() { path.clone() } else { 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 total_bytes = vfs.blocks() * frsize; - let free_bytes = vfs.blocks_available() * frsize; + let disks = Disks::new_with_refreshed_list(); + let disk = disks + .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 { manga_bytes, @@ -64,10 +68,8 @@ fn get_storage_info(downloads_path: String) -> Result { } /// Returns the true OS-level scale factor for the main window. -/// This reads directly from the underlying winit window handle, bypassing -/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio. -/// This is the only reliable way to get the correct DPR in all launch -/// environments — tauri dev, nix run, flatpak, etc. +/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio. +/// On macOS the value comes directly from the native window. #[tauri::command] fn get_scale_factor(window: tauri::Window) -> f64 { window.scale_factor().unwrap_or(1.0) @@ -80,12 +82,159 @@ fn kill_tachidesk(app: &tauri::AppHandle) { let _ = child.kill(); 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") .arg("-f") .arg("tachidesk") .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 = ` 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 = 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 { + 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 - 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] fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); @@ -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(); - 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)) => { - println!("Spawned server: {}", binary); + println!("Spawned server: {:?}", bin); let mut guard = state.0.lock().unwrap(); *guard = Some(child); Ok(()) } Err(e) => { - eprintln!("Failed to spawn {}: {}", binary, e); + eprintln!("Failed to spawn {:?}: {}", bin, e); Err(e.to_string()) } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2378c5b..8819ee5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,18 +26,31 @@ }, "bundle": { "active": true, - "targets": ["appimage"], + "targets": ["appimage", "dmg", "macos"], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "externalBin": [ + "binaries/suwayomi-server" + ], + "resources": { + "binaries/suwayomi-bundle": "suwayomi-bundle" + }, + "macOS": { + "minimumSystemVersion": "11.0", + "exceptionDomain": "localhost" + } }, "plugins": { "shell": { - "open": true + "open": true, + "sidecar": [ + "binaries/suwayomi-server" + ] } } -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index bdf515c..72974ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ export default function App() { const prevQueueRef = useRef([]); const idleTimerRef = useRef | null>(null); + const idleRef = useRef(false); // expose devSplash trigger via window for settings useEffect(() => { @@ -43,10 +44,15 @@ export default function App() { 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(() => { if (!appReady) return; 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); const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000; if (idleTimeoutMs === 0) return; @@ -178,7 +184,7 @@ export default function App() { { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }} + onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }} /> )} {!activeChapter && } diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx index acd7ac6..bc46cf2 100644 --- a/src/components/explore/Explore.tsx +++ b/src/components/explore/Explore.tsx @@ -6,7 +6,7 @@ import { UPDATE_MANGA } from "../../lib/queries"; import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; -import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; +import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; import SourceList from "../sources/SourceList"; @@ -177,6 +177,35 @@ export default function Explore() { 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() { const [allManga, setAllManga] = useState([]); 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(() => { - // If we already have data, no need to re-fetch (cache hit path) - const alreadyLoaded = allManga.length > 0 && sources.length > 0; + const alreadyLoaded = allManga.length > 0; if (alreadyLoaded) return; setLoadingLib(true); @@ -249,39 +279,29 @@ function ExploreFeed() { setLoadError(false); const preferredLang = settings.preferredExtensionLang || "en"; - - // Clear stale failed cache entries so we actually retry if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresRef.current = ""; } - // Library — fire immediately, independent of sources + // Single query for all manga — library flag included cache.get(CACHE_KEYS.LIBRARY, () => - Promise.all([ - gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), - gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY), - ]).then(([all, lib]) => { - const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m])); - return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m); - }) + gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA) + .then((d) => d.mangas.nodes) ).then(setAllManga) .catch((e) => { console.error(e); setLoadError(true); }) .finally(() => setLoadingLib(false)); - // Sources — then kick off popular AND genres simultaneously + // Sources — only needed for Popular section cache.get(CACHE_KEYS.SOURCES, () => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => dedupeSources(d.sources.nodes, preferredLang)) ).then((allSources) => { - if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; } - - // Cap to 2 sources for the explore feed — halves the network calls + if (allSources.length === 0) { setLoadingPopular(false); return; } const topSources = getTopSources(allSources).slice(0, 2); setSources(allSources); - // ── Popular — don't block genres ────────────────────────────────── cache.get(CACHE_KEYS.POPULAR, () => Promise.allSettled( topSources.map((src) => @@ -296,48 +316,7 @@ function ExploreFeed() { return dedupeMangaByTitle(merged).slice(0, 30); }) ).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false)); - - // ── Genres — start immediately alongside popular using foundational - // genres as a starting point; personalized genres replace these once - // library loads. Results stream in as each genre resolves. - const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3); - const genreKey = genresToFetch.join(","); - if (fetchedGenresRef.current === genreKey) return; - fetchedGenresRef.current = genreKey; - - setLoadingGenres(true); - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - const streamingMap = new Map(); - 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); }); + }).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [retryCount]); @@ -367,12 +346,13 @@ function ExploreFeed() { .map(([g]) => g); }, [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(() => { - if (frecencyGenres.length === 0 || sources.length === 0) return; + if (frecencyGenres.length === 0 || allManga.length === 0) return; const genreKey = frecencyGenres.join(","); - if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit + if (fetchedGenresRef.current === genreKey) return; fetchedGenresRef.current = genreKey; setLoadingGenres(true); @@ -380,24 +360,16 @@ function ExploreFeed() { const ctrl = new AbortController(); abortRef.current = ctrl; - const topSources = getTopSources(sources).slice(0, 2); const streamingMap = new Map(); Promise.allSettled( frecencyGenres.map((genre) => cache.get(CACHE_KEYS.GENRE(genre), () => - Promise.allSettled( - topSources.map((src) => - gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page: 1, query: genre, - }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) - ) - ).then((results) => { - const merged: Manga[] = []; - for (const r of results) - if (r.status === "fulfilled") merged.push(...r.value); - return dedupeMangaByTitle(merged).slice(0, 24); - }) + gql<{ mangas: { nodes: Manga[] } }>( + MANGAS_BY_GENRE_EXPLORE, + { genre, first: 25 }, + ctrl.signal, + ).then((d) => d.mangas.nodes) ).then((mangas) => { if (ctrl.signal.aborted) return; streamingMap.set(genre, mangas); @@ -407,7 +379,7 @@ function ExploreFeed() { ) .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); - }, [frecencyGenres, sources]); + }, [frecencyGenres, allManga]); function openManga(m: Manga) { setPreviewManga(m); } diff --git a/src/components/explore/GenreDrillPage.tsx b/src/components/explore/GenreDrillPage.tsx index 36328dc..b94f805 100644 --- a/src/components/explore/GenreDrillPage.tsx +++ b/src/components/explore/GenreDrillPage.tsx @@ -2,22 +2,54 @@ import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS } from "../../lib/cache"; +import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; import { useStore } from "../../store"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import type { Manga, Source } from "../../lib/types"; import s from "./GenreDrillPage.module.css"; -// ── Constants ────────────────────────────────────────────────────────────────── -const PAGE_SIZE = 50; // how many items to show at once -const INITIAL_PAGES = 3; // source API pages to fetch upfront per source -const MAX_SOURCES = 12; // max sources to query concurrently -const CONCURRENCY = 4; // parallel source fetches +// ── Constants ───────────────────────────────────────────────────────────────── +const PAGE_SIZE = 50; +const INITIAL_PAGES = 3; +const MAX_SOURCES = 12; +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( items: T[], - fn: (item: T) => Promise, + fn: (item: T) => Promise, signal: AbortSignal, ): Promise { let i = 0; @@ -46,7 +78,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; // ── GenreDrillPage ──────────────────────────────────────────────────────────── export default function GenreDrillPage() { - const genre = useStore((st) => st.genreFilter); + const genreFilter = useStore((st) => st.genreFilter); const setGenreFilter = useStore((st) => st.setGenreFilter); const setPreviewManga = useStore((st) => st.setPreviewManga); const settings = useStore((st) => st.settings); @@ -54,6 +86,11 @@ export default function GenreDrillPage() { const addFolder = useStore((st) => st.addFolder); 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([]); const [sourceManga, setSourceManga] = useState([]); 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); // Per-source next-page tracker; -1 means exhausted - const nextPageRef = useRef>(new Map()); - const sourcesRef = useRef([]); - const abortRef = useRef(null); + const nextPageRef = useRef>(new Map()); + const sourcesRef = useRef([]); + const abortRef = useRef(null); + // ── Initial load ───────────────────────────────────────────────────────── useEffect(() => { - if (!genre) return; + if (tags.length === 0) return; abortRef.current?.abort(); const ctrl = new AbortController(); @@ -81,7 +119,7 @@ export default function GenreDrillPage() { const preferredLang = settings.preferredExtensionLang || "en"; - // ── Library (fire-and-forget, doesn't block skeleton removal) ───────── + // ── Library (local DB, instant) ─────────────────────────────────────── cache.get(CACHE_KEYS.LIBRARY, () => Promise.all([ gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), @@ -94,46 +132,67 @@ export default function GenreDrillPage() { .then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); }) .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); - // ── Sources: stream results in as each source responds ──────────────── + // ── Sources: stream results as each source responds ─────────────────── + // Source list is stable within a session — cache indefinitely. cache.get(CACHE_KEYS.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) => { const sources = allSources.slice(0, MAX_SOURCES); sourcesRef.current = sources; - // Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page for (const src of sources) nextPageRef.current.set(src.id, -1); await runConcurrent(sources, async (src) => { if (ctrl.signal.aborted) return; + + // 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[] = []; + for (let page = 1; page <= INITIAL_PAGES; page++) { if (ctrl.signal.aborted) return; - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: genre }, - ctrl.signal, - ); - pageItems.push(...d.fetchSourceManga.mangas); - if (!d.fetchSourceManga.hasNextPage) { - nextPageRef.current.set(src.id, -1); - break; - } else if (page === INITIAL_PAGES) { - // Has more pages beyond what we fetched upfront — mark for "load more" - nextPageRef.current.set(src.id, INITIAL_PAGES + 1); - } - } catch (e: any) { - if (e?.name === "AbortError") return; + + const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags); + const result = await cache + .get<{ mangas: Manga[]; hasNextPage: boolean }>( + pageKey, + () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, 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) 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); break; + } else if (page === INITIAL_PAGES) { + nextPageRef.current.set(src.id, INITIAL_PAGES + 1); } } + if (!ctrl.signal.aborted && pageItems.length > 0) { - // Dedupe by ID only — title dedup across sources is too aggressive and collapses - // legitimate different-source results that share a common title (e.g. "Action" genre) setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems])); - // Drop the skeleton as soon as we have anything setLoadingInitial(false); } }, ctrl.signal); @@ -145,34 +204,35 @@ export default function GenreDrillPage() { }); 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 libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); - const libIds = new Set(libMatches.map((m) => m.id)); - const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); - return dedupeMangaById([...libMatches, ...srcAll]); - }, [libraryManga, sourceManga, genre]); + // For multi-tag: library results must match ALL tags + const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags)); + const libIds = new Set(libMatches.map((m) => m.id)); + const srcOnly = sourceManga.filter((m) => !libIds.has(m.id)); + return dedupeMangaById([...libMatches, ...srcOnly]); + }, [libraryManga, sourceManga, tags]); - // ── Load more ────────────────────────────────────────────────────────────── - const hasMoreVisible = visibleCount < filtered.length; - const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); - const hasMore = hasMoreVisible || hasMoreNetwork; + // ── Load more ───────────────────────────────────────────────────────────── + const hasMoreVisible = visibleCount < filtered.length; + const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); + const hasMore = hasMoreVisible || hasMoreNetwork; const loadMore = useCallback(async () => { if (loadingMore) return; - // If there are buffered results, just reveal the next page + // Fast path: buffered results already in memory if (hasMoreVisible) { setVisibleCount((v) => v + PAGE_SIZE); return; } - // Fetch next pages from network - const sources = sourcesRef.current.filter( - (src) => (nextPageRef.current.get(src.id) ?? -1) > 0 - ); + // Slow path: fetch next pages from sources + const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); if (!sources.length) return; setLoadingMore(true); @@ -184,18 +244,35 @@ export default function GenreDrillPage() { await runConcurrent(sources, async (src) => { const page = nextPageRef.current.get(src.id)!; if (ctrl.signal.aborted) return; - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: genre }, - ctrl.signal, - ); - nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); - if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) - setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); - } catch (e: any) { - if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); - } + + const ps = getPageSet(src.id, "SEARCH", tags); + const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags); + + const result = await cache + .get<{ mangas: Manga[]; hasNextPage: boolean }>( + pageKey, + () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: primaryTag }, + 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); } finally { if (!ctrl.signal.aborted) { @@ -203,7 +280,7 @@ export default function GenreDrillPage() { setLoadingMore(false); } } - }, [loadingMore, hasMoreVisible, genre]); + }, [loadingMore, hasMoreVisible, primaryTag, tags]); // ── Context menu ────────────────────────────────────────────────────────── function openCtx(e: React.MouseEvent, m: Manga) { @@ -245,6 +322,7 @@ export default function GenreDrillPage() { } const visibleItems = filtered.slice(0, visibleCount); + const label = tagsLabel(tags); return (
@@ -253,7 +331,7 @@ export default function GenreDrillPage() { Back - {genre} + {label} {loadingInitial && filtered.length === 0 ? null : ( {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} @@ -274,7 +352,7 @@ export default function GenreDrillPage() { ))}
) : filtered.length === 0 ? ( -
No manga found for "{genre}".
+
No manga found for "{label}".
) : (
{visibleItems.map((m) => ( @@ -290,8 +368,8 @@ export default function GenreDrillPage() {
)} diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx index fe55e31..cd4ca96 100644 --- a/src/components/layout/SplashScreen.tsx +++ b/src/components/layout/SplashScreen.tsx @@ -428,10 +428,15 @@ export default function SplashScreen({ useEffect(() => { if (mode !== "idle" || !onDismiss) return; function handler() { triggerExit(onDismiss); } - window.addEventListener("keydown", handler, { once: true }); - window.addEventListener("mousedown", handler, { once: true }); - window.addEventListener("touchstart", handler, { once: true }); + // Delay registering listeners by one frame so the event that triggered + // idle (mousemove/mousedown) doesn't immediately dismiss the splash. + const t = setTimeout(() => { + window.addEventListener("keydown", handler, { once: true }); + window.addEventListener("mousedown", handler, { once: true }); + window.addEventListener("touchstart", handler, { once: true }); + }, 200); return () => { + clearTimeout(t); window.removeEventListener("keydown", handler); window.removeEventListener("mousedown", handler); window.removeEventListener("touchstart", handler); diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css index fbb4c41..24eecff 100644 --- a/src/components/pages/Search.module.css +++ b/src/components/pages/Search.module.css @@ -339,4 +339,73 @@ letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); 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; } \ No newline at end of file diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx index 4113993..2416f1e 100644 --- a/src/components/pages/Search.tsx +++ b/src/components/pages/Search.tsx @@ -1,10 +1,10 @@ import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react"; import { - MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, + MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, Globe, } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; 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 { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; @@ -13,18 +13,21 @@ import s from "./Search.module.css"; // ── Types ───────────────────────────────────────────────────────────────────── type SearchTab = "keyword" | "tag" | "source"; +type TagMode = "AND" | "OR"; interface SourceResult { source: Source; mangas: Manga[]; loading: boolean; - error: string | null; + error: string | null; } // ── Constants ───────────────────────────────────────────────────────────────── -const CONCURRENCY = 4; +const CONCURRENCY = 4; 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 = [ "Action","Adventure","Comedy","Drama","Fantasy","Romance", @@ -34,11 +37,11 @@ const COMMON_GENRES = [ "Magic","Music","Cooking","Medical","Military","Harem","Ecchi", ]; -// ── Concurrent fetch helper ─────────────────────────────────────────────────── +// ── Shared helpers ──────────────────────────────────────────────────────────── async function runConcurrent( items: T[], - fn: (item: T) => Promise, + fn: (item: T) => Promise, signal: AbortSignal, ): Promise { let i = 0; @@ -52,7 +55,13 @@ async function runConcurrent( 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({ src, alt, className, @@ -114,7 +123,7 @@ export default function Search() { const setSearchPrefill = useStore((st) => st.setSearchPrefill); const setPreviewManga = useStore((st) => st.setPreviewManga); - const [allSources, setAllSources] = useState([]); + const [allSources, setAllSources] = useState([]); const [loadingSources, setLoadingSources] = useState(false); const pendingPrefill = useRef(""); @@ -132,7 +141,8 @@ export default function Search() { setLoadingSources(true); cache.get(CACHE_KEYS.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) .catch(console.error) @@ -194,25 +204,26 @@ export default function Search() { } // ── Keyword tab ─────────────────────────────────────────────────────────────── +// Unchanged from v1. function KeywordTab({ allSources, loadingSources, availableLangs, hasMultipleLangs, preferredLang, pendingPrefill, onMangaClick, }: { - allSources: Source[]; - loadingSources: boolean; - availableLangs: string[]; + allSources: Source[]; + loadingSources: boolean; + availableLangs: string[]; hasMultipleLangs: boolean; - preferredLang: string; - pendingPrefill: React.MutableRefObject; - onMangaClick: (m: Manga) => void; + preferredLang: string; + pendingPrefill: React.MutableRefObject; + onMangaClick: (m: Manga) => void; }) { const [query, setQuery] = useState(""); const [submitted, setSubmitted] = useState(""); const [results, setResults] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); const [selectedLangs, setSelectedLangs] = useState>(new Set()); - const [includeNsfw, setIncludeNsfw] = useState(false); + const [includeNsfw, setIncludeNsfw] = useState(false); const abortRef = useRef(null); const inputRef = useRef(null); @@ -427,151 +438,273 @@ function KeywordTab({ } // ── 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 TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load -const TAG_MAX_SOURCES = 12; // max sources to query +const MANGAS_BY_GENRE = ` + query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { + mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) { + nodes { + id title thumbnailUrl inLibrary genre status + source { id displayName } + } + pageInfo { hasNextPage } + totalCount + } + } +`; + +function buildGenreFilter(tags: string[], mode: TagMode): Record { + 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({ - preferredLang, onMangaClick, + allSources, + loadingSources, + preferredLang, + onMangaClick, }: { - allSources: Source[]; + allSources: Source[]; loadingSources: boolean; - preferredLang: string; - onMangaClick: (m: Manga) => void; + preferredLang: string; + onMangaClick: (m: Manga) => void; }) { - const [activeTag, setActiveTag] = useState(null); - const [tagResults, setTagResults] = useState([]); - const [loadingTag, setLoadingTag] = useState(false); - 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>(new Map()); - const sourcesRef = useRef([]); - const abortRef = useRef(null); + const [activeTags, setActiveTags] = useState([]); + const [tagMode, setTagMode] = useState("AND"); + const [tagFilter, setTagFilter] = useState(""); - useEffect(() => () => { abortRef.current?.abort(); }, []); + // ── Local DB state ──────────────────────────────────────────────────────── + const [localResults, setLocalResults] = useState([]); + 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(null); - async function drillTag(tag: string) { - if (tag === activeTag && !loadingTag) return; - setActiveTag(tag); - setTagResults([]); - setLoadingTag(true); - setVisibleCount(TAG_PAGE_SIZE); - nextPageRef.current = new Map(); + // ── Source search state ─────────────────────────────────────────────────── + const [searchSources, setSearchSources] = useState(false); + const [sourceResults, setSourceResults] = useState([]); + const [loadingSourceSearch, setLoadingSourceSearch] = useState(false); + const [loadingMoreSource, setLoadingMoreSource] = useState(false); + // Per-source next-page tracker; -1 = exhausted + const srcNextPageRef = useRef>(new Map()); + const abortSourceRef = useRef(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(); - abortRef.current = ctrl; + abortLocalRef.current = ctrl; + setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false); + setLoadingLocal(true); - try { - const sources = await cache.get(CACHE_KEYS.SOURCES, () => - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => d.sources.nodes.filter((s) => s.id !== "0")) - ); - const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES); - sourcesRef.current = deduped; + gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>( + MANGAS_BY_GENRE, + { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 }, + ctrl.signal, + ).then((d) => { + if (ctrl.signal.aborted) return; + 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 - for (const src of deduped) { - nextPageRef.current.set(src.id, -1); + // ── Source search ───────────────────────────────────────────────────────── + // Fires when toggled on (or when tags change while already on). + // 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 - await runConcurrent(deduped, async (src) => { - if (ctrl.signal.aborted) return; - const pageResults: Manga[] = []; - // Fetch TAG_FETCH_PAGES pages in series per source - for (let page = 1; page <= TAG_FETCH_PAGES; page++) { - if (ctrl.signal.aborted) return; - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: tag }, - ctrl.signal, - ); - pageResults.push(...d.fetchSourceManga.mangas); - if (!d.fetchSourceManga.hasNextPage) { - nextPageRef.current.set(src.id, -1); // no more pages - break; - } else if (page === TAG_FETCH_PAGES) { - // Still has more pages beyond what we fetched upfront - nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1); - } - } catch (e: any) { - if (e?.name === "AbortError") return; - break; // source error — move on - } - } - if (!ctrl.signal.aborted && pageResults.length > 0) { - setTagResults((prev) => dedupeMangaById([...prev, ...pageResults])); - } - }, ctrl.signal); + return () => { ctrl.abort(); }; + }, [searchSources, activeTags, allSources, loadingSources]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Load more: local ────────────────────────────────────────────────────── + async function loadMoreLocal() { + if (loadingMoreLocal || !localHasNext) return; + setLoadingMoreLocal(true); + abortLocalRef.current?.abort(); + const ctrl = new AbortController(); + abortLocalRef.current = ctrl; + try { + const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>( + MANGAS_BY_GENRE, + { filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset }, + ctrl.signal, + ); + if (ctrl.signal.aborted) return; + setLocalResults((prev) => [...prev, ...d.mangas.nodes]); + setLocalHasNext(d.mangas.pageInfo.hasNextPage); + setLocalOffset((o) => o + TAG_PAGE_SIZE); } catch (e: any) { if (e?.name !== "AbortError") console.error(e); } finally { - if (!ctrl.signal.aborted) setLoadingTag(false); + if (!ctrl.signal.aborted) setLoadingMoreLocal(false); } } - async function loadMore() { - if (!activeTag || loadingMore) return; + // ── Load more: sources ──────────────────────────────────────────────────── + const sourceHasMore = searchSources && + [...srcNextPageRef.current.values()].some((p) => p > 0); - // First check if we have more buffered results to show - if (visibleCount < tagResults.length) { - setVisibleCount((v) => v + TAG_PAGE_SIZE); - return; - } - - // 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(); + async function loadMoreSource() { + if (loadingMoreSource || !sourceHasMore) return; + setLoadingMoreSource(true); + abortSourceRef.current?.abort(); 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 { - await runConcurrent(sourcesToFetch, async (src) => { - const page = nextPageRef.current.get(src.id)!; + await runConcurrent(sources, async (src) => { + const page = srcNextPageRef.current.get(src.id)!; if (ctrl.signal.aborted) return; - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page, query: activeTag }, - ctrl.signal, - ); - nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); - if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) { - setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); - } - } catch (e: any) { - if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); - } + + const ps = getPageSet(src.id, "SEARCH", activeTags); + const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags); + + const result = await cache + .get<{ mangas: Manga[]; hasNextPage: boolean }>( + pageKey, + () => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: primaryTag }, + ctrl.signal, + ).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); } finally { - if (!ctrl.signal.aborted) { - setVisibleCount((v) => v + TAG_PAGE_SIZE); - setLoadingMore(false); - } + if (!ctrl.signal.aborted) setLoadingMoreSource(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 q = tagFilter.trim().toLowerCase(); return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; }, [tagFilter]); - const visibleResults = tagResults.slice(0, visibleCount); - const hasMore = visibleCount < tagResults.length || - sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); + const hasActiveTags = activeTags.length > 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 (
+ {/* ── Sidebar ────────────────────────────────────────────────────── */}
@@ -586,53 +719,130 @@ function TagTab({ {filteredGenres.map((tag) => ( ))} {filteredGenres.length === 0 &&

No matching tags

}
+ {/* ── Content ────────────────────────────────────────────────────── */}
- {!activeTag ? ( + {!hasActiveTags ? (

Browse by tag

-

Select a genre tag to see matching manga across your sources.

+

Select one or more genre tags to find matching manga.

) : ( <> + {/* Active tag pills + controls */} +
+
+ {activeTags.map((tag) => ( + + {tag} + + + ))} +
+
+ {activeTags.length > 1 && ( +
+ + +
+ )} + {/* "Search sources" toggle — fetches from external sources */} + + +
+
+ + {/* Result header */}
- {activeTag} - {loadingTag + + {activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`} + {searchSources && ( + + + sources + + )} + + {(loadingLocal || loadingSourceSearch) ? : - {visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results - } + {totalVisible} + {localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results + + }
- {loadingTag ? ( - - ) : tagResults.length > 0 ? ( + + {/* Results grid */} + {loadingLocal ? ( + + ) : mergedResults.length > 0 ? (
- {visibleResults.map((m) => ( + {mergedResults.map((m) => ( onMangaClick(m)} /> ))} - {hasMore && ( + + {/* Inline skeletons while source results are still streaming in */} + {loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ ))} + + {/* Show more buttons — one per data source */} + {(localHasNext || sourceHasMore) && (
- + {localHasNext && ( + + )} + {sourceHasMore && ( + + )}
)}
) : (
-

No results for "{activeTag}"

+

No results for {activeTags.join(` ${tagMode} `)}

+

+ {searchSources + ? "Try OR mode or broader tags." + : "Try OR mode, enable Sources, or check that these manga are in your library."} +

)} @@ -643,15 +853,16 @@ function TagTab({ } // ── Source tab ──────────────────────────────────────────────────────────────── +// Unchanged from v1. function SourceTab({ allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick, }: { - allSources: Source[]; - loadingSources: boolean; - availableLangs: string[]; + allSources: Source[]; + loadingSources: boolean; + availableLangs: string[]; hasMultipleLangs: boolean; - onMangaClick: (m: Manga) => void; + onMangaClick: (m: Manga) => void; }) { const [selectedLang, setSelectedLang] = useState("all"); const [activeSource, setActiveSource] = useState(null); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 8534af2..173968b 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -1,37 +1,73 @@ /** * 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). * - 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. * 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). * - 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>(); + +interface Entry { + promise: Promise; + fetchedAt: number; // ms since epoch +} + +const store = new Map>(); const subs = new Map void>>(); +/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */ +export const DEFAULT_TTL_MS = 5 * 60 * 1_000; + export const cache = { - get(key: string, fetcher: () => Promise): Promise { - if (!store.has(key)) { - store.set(key, fetcher().catch((err) => { - // Only evict on real failures, not user cancellations - if (err?.name !== "AbortError") store.delete(key); - return Promise.reject(err); - })); - } - return store.get(key) as Promise; + /** + * Return a cached promise. + * Re-fetches automatically once the entry is older than `ttl` ms. + * Pass `Infinity` to cache for the entire session (e.g. source/extension lists). + */ + get(key: string, fetcher: () => Promise, ttl: number = DEFAULT_TTL_MS): Promise { + const existing = store.get(key) as Entry | undefined; + if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise; + + 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; + + store.set(key, { promise, fetchedAt: Date.now() }); + return promise; }, + 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) { store.delete(key); subs.get(key)?.forEach((cb) => cb()); }, + clearAll() { store.clear(); subs.forEach((set) => set.forEach((cb) => cb())); }, + /** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */ subscribe(key: string, cb: () => void): () => void { 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 = { LIBRARY: "library", SOURCES: "sources", @@ -48,15 +85,45 @@ export const CACHE_KEYS = { GENRE: (genre: string) => `genre:${genre}`, MANGA: (id: number) => `manga:${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; -// ── 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 // 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 -// a time per key — regardless of how many components request it simultaneously. -// +// a time per key. + const inflight = new Map>(); export function deduped(key: string, fetcher: () => Promise): Promise { @@ -66,18 +133,56 @@ export function deduped(key: string, fetcher: () => Promise): Promise { 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>(); + +export interface PageSet { + add(page: number): void; + pages(): Set; + /** 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; type FrecencyMap = Record; function loadFrecency(): FrecencyMap { - try { - const raw = localStorage.getItem(FRECENCY_KEY); - return raw ? JSON.parse(raw) : {}; - } catch { return {}; } + try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } + catch { return {}; } } function saveFrecency(map: FrecencyMap) { @@ -95,7 +200,6 @@ export function getTopSources(sources: T[]): T[] { const map = loadFrecency(); const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 })); const hasFrecency = withScore.some((x) => x.score > 0); - if (hasFrecency) { return withScore .sort((a, b) => b.score - a.score)