[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:
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
+135 -32
View File
@@ -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",
+1 -1
View File
@@ -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]
+177 -14
View File
@@ -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<StorageInfo, String> {
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<StorageInfo, String> {
}
/// 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 = <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]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
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();
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())
}
}
+16 -3
View File
@@ -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"
]
}
}
}
+8 -2
View File
@@ -36,6 +36,7 @@ export default function App() {
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | 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() {
<SplashScreen
mode="idle"
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
/>
)}
{!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 { 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<Manga[]>([]);
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<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); });
}).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<string, Manga[]>();
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); }
+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 { 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<T>(
items: T[],
fn: (item: T) => Promise<void>,
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
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<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
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<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(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 (
<div className={s.root}>
@@ -253,7 +331,7 @@ export default function GenreDrillPage() {
<ArrowLeft size={13} weight="light" />
<span>Back</span>
</button>
<span className={s.title}>{genre}</span>
<span className={s.title}>{label}</span>
{loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
@@ -274,7 +352,7 @@ export default function GenreDrillPage() {
))}
</div>
) : 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}>
{visibleItems.map((m) => (
@@ -290,8 +368,8 @@ export default function GenreDrillPage() {
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading</>
: `Show more`}
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading</>
: "Show more"}
</button>
</div>
)}
+8 -3
View File
@@ -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);
+69
View File
@@ -340,3 +340,72 @@
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;
}
+358 -147
View File
@@ -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<T>(
items: T[],
fn: (item: T) => Promise<void>,
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
@@ -52,7 +55,13 @@ async function runConcurrent<T>(
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<Source[]>([]);
const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const pendingPrefill = useRef<string>("");
@@ -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<string>;
onMangaClick: (m: Manga) => void;
preferredLang: string;
pendingPrefill: React.MutableRefObject<string>;
onMangaClick: (m: Manga) => void;
}) {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
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 inputRef = useRef<HTMLInputElement>(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<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({
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<string | null>(null);
const [tagResults, setTagResults] = useState<Manga[]>([]);
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<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
const [activeTags, setActiveTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<TagMode>("AND");
const [tagFilter, setTagFilter] = useState("");
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) {
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<Manga[]>([]);
const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
const [loadingMoreSource, setLoadingMoreSource] = useState(false);
// Per-source next-page tracker; -1 = exhausted
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();
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 (
<div className={s.splitRoot}>
{/* ── Sidebar ────────────────────────────────────────────────────── */}
<div className={s.splitSidebar}>
<div className={s.splitSearchWrap}>
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
@@ -586,53 +719,130 @@ function TagTab({
{filteredGenres.map((tag) => (
<button
key={tag}
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
onClick={() => drillTag(tag)}
className={[s.splitItem, activeTags.includes(tag) ? s.splitItemActive : ""].join(" ")}
onClick={() => toggleTag(tag)}
>
{tag}
<span className={s.splitItemLabel}>{tag}</span>
{activeTags.includes(tag) && <span className={s.tagCheckMark}></span>}
</button>
))}
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
</div>
</div>
{/* ── Content ────────────────────────────────────────────────────── */}
<div className={s.splitContent}>
{!activeTag ? (
{!hasActiveTags ? (
<div className={s.empty}>
<Hash size={32} weight="light" className={s.emptyIcon} />
<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>
) : (
<>
{/* 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}>
<span className={s.splitContentTitle}>{activeTag}</span>
{loadingTag
<span className={s.splitContentTitle}>
{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)" }} />
: <span className={s.splitResultCount}>
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
</span>}
{totalVisible}
{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
</span>
}
</div>
{loadingTag ? (
<GridSkeleton count={50} />
) : tagResults.length > 0 ? (
{/* Results grid */}
{loadingLocal ? (
<GridSkeleton count={48} />
) : mergedResults.length > 0 ? (
<div className={s.tagGrid}>
{visibleResults.map((m) => (
{mergedResults.map((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}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more"}
</button>
{localHasNext && (
<button className={s.showMoreBtn} onClick={loadMoreLocal} disabled={loadingMoreLocal}>
{loadingMoreLocal
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "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 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>
)}
</>
@@ -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<string>("all");
const [activeSource, setActiveSource] = useState<Source | null>(null);
+126 -22
View File
@@ -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<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>>();
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
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<T>;
/**
* 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<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
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); },
/** 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<string, Promise<unknown>>();
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;
}
// ── 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;
type FrecencyMap = Record<string, number>;
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<T extends { id: string }>(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)