Compare commits

..

4 Commits

Author SHA1 Message Date
Youwes09 6a8b6e7f93 Fix: Inject Bundle Resources Via --config Flag 2026-03-20 21:34:42 -05:00
Youwes09 a452cdc2e3 Fix: Branch Input to Windows Workflow 2026-03-20 21:25:23 -05:00
Youwes09 71a6eb02b5 Fix: Use jq in Workflow 2026-03-20 21:22:52 -05:00
Youwes09 50e981574a Add: Windows Build Workflow 2026-03-20 21:16:11 -05:00
477 changed files with 21585 additions and 58294 deletions
-171
View File
@@ -1,171 +0,0 @@
name: Build Linux
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g. 0.9.0)"
required: true
permissions:
contents: write
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist-linux
path: dist/
retention-days: 1
tauri:
name: Tauri (Linux x64)
needs: frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist-linux
path: dist/
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libfuse2
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Download Suwayomi (Linux x64)
run: |
curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
-o suwayomi-linux.tar.gz
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
mkdir -p suwayomi-extracted
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
- name: Stage Suwayomi bundle
run: |
mkdir -p src-tauri/binaries
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
JAVA="suwayomi-extracted/jre/bin/java"
CATCH="suwayomi-extracted/bin/catch_abort.so"
for f in "$JAR" "$JAVA" "$CATCH"; do
if [ ! -e "$f" ]; then
echo "ERROR: expected file not found: $f"
find suwayomi-extracted -type f | head -40
exit 1
fi
done
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
- name: Stage Linux launcher sidecar
run: |
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
- name: Patch tauri.conf.json for CI
run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Build Tauri app
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
env:
NO_STRIP: "true"
- name: Upload Linux artifacts to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }}
run: |
for i in $(seq 1 12); do
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i"
sleep 15
done
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Could not find release for v$VERSION after waiting"
exit 1
fi
echo "Found release ID: $RELEASE_ID"
upload_asset() {
local file="$1"
local name="$2"
echo "Uploading $name..."
curl -s -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
}
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
+129 -75
View File
@@ -4,12 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version to build (e.g. 0.4.0)" description: "Version to build (e.g. 0.3.0)"
required: true required: true
permissions:
contents: write
jobs: jobs:
frontend: frontend:
name: Build frontend name: Build frontend
@@ -43,6 +40,9 @@ jobs:
name: Tauri (macOS) name: Tauri (macOS)
needs: frontend needs: frontend
runs-on: macos-latest runs-on: macos-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -79,7 +79,7 @@ jobs:
download_suwayomi() { download_suwayomi() {
local asset="$1" sha="$2" outdir="$3" local asset="$1" sha="$2" outdir="$3"
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \ "https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
-o "${outdir}.tar.gz" -o "${outdir}.tar.gz"
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c - echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
mkdir -p "${outdir}" mkdir -p "${outdir}"
@@ -87,108 +87,162 @@ jobs:
} }
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \ "Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \ "c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
"suwayomi-arm64" "suwayomi-arm64"
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \ "Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \ "c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
"suwayomi-x64" "suwayomi-x64"
- name: Stage Suwayomi sidecars - name: Stage Suwayomi sidecars
run: | run: |
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
stage_arch() { find_launcher() {
local srcdir="$1" local dir="$1"
local arch="$2" # v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
local sidecar="src-tauri/binaries/suwayomi-server-${arch}" find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
if [ -z "$JAR" ]; then
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
find "$srcdir" -type f | head -30
exit 1
fi
if [ -z "$JAVA" ]; then
echo "ERROR: jre/bin/java not found in $srcdir"
find "$srcdir" -type f | head -30
exit 1
fi
echo "${arch}: jar=${JAR} java=${JAVA}"
cp -r "$srcdir" "$bundle_dest"
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
chmod +x "$sidecar"
echo "Staged sidecar: $sidecar"
} }
stage_arch suwayomi-arm64 aarch64-apple-darwin ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
stage_arch suwayomi-x64 x86_64-apple-darwin X64_LAUNCHER=$(find_launcher suwayomi-x64)
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
echo "ERROR: could not find launchers — tarball contents:"
ls -lR suwayomi-arm64 suwayomi-x64
exit 1
fi
echo "arm64 launcher: $ARM_LAUNCHER"
echo "x64 launcher: $X64_LAUNCHER"
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
# We stage both arch bundles and swap the symlink before each build.
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
- name: Patch tauri.conf.json for CI - name: Patch tauri.conf.json for CI
run: | run: |
# dist/ is already built by the frontend job — suppress the rebuild.
# We patch in-place rather than using --config to avoid Tauri schema issues.
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Swap bundle for aarch64 - name: Swap bundle for aarch64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \ cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (aarch64) - name: Build Tauri app (aarch64)
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
with:
args: --target aarch64-apple-darwin
- name: Swap bundle for x86_64 - name: Swap bundle for x86_64
run: | run: |
rm -rf src-tauri/binaries/suwayomi-bundle rm -rf src-tauri/binaries/suwayomi-bundle
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \ cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
src-tauri/binaries/suwayomi-bundle
- name: Build Tauri app (x86_64) - name: Build Tauri app (x86_64)
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json uses: tauri-apps/tauri-action@v0
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
- name: Upload macOS artifacts to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.event.inputs.version }} # Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
with:
args: --target x86_64-apple-darwin
- name: Upload arm64 .dmg
uses: actions/upload-artifact@v4
with:
name: moku-aarch64
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
- name: Upload x64 .dmg
uses: actions/upload-artifact@v4
with:
name: moku-x86_64
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
- name: Upload arm64 .app (for universal job)
uses: actions/upload-artifact@v4
with:
name: app-aarch64-apple-darwin
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
retention-days: 1
- name: Upload x64 .app (for universal job)
uses: actions/upload-artifact@v4
with:
name: app-x86_64-apple-darwin
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
retention-days: 1
universal:
name: Universal .dmg
needs: tauri
runs-on: macos-latest
steps:
- name: Download arm64 .app
uses: actions/download-artifact@v4
with:
name: app-aarch64-apple-darwin
path: apps/arm64/
- name: Download x64 .app
uses: actions/download-artifact@v4
with:
name: app-x86_64-apple-darwin
path: apps/x64/
- name: lipo into universal binary
run: | run: |
# Wait for the Windows workflow to have created the draft release ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
for i in $(seq 1 12); do X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1) APP_NAME=$(basename "$ARM_APP")
if [ -n "$RELEASE_ID" ]; then break; fi
echo "Waiting for release to exist... attempt $i" mkdir -p universal
sleep 15 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 done
if [ -z "$RELEASE_ID" ]; then - name: Package universal .dmg
echo "ERROR: Could not find release for v$VERSION after waiting" run: |
exit 1 APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
fi 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"
echo "Found release ID: $RELEASE_ID" - name: Upload universal .dmg
uses: actions/upload-artifact@v4
upload_asset() { with:
local file="$1" name: moku-universal
local name="$2" path: moku-universal.dmg
echo "Uploading $name..." retention-days: 7
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
}
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
+43 -67
View File
@@ -4,11 +4,12 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version to build (e.g. 0.9.0)" description: "Version to build (e.g. 0.4.0)"
required: true required: true
branch:
permissions: description: "Branch to build (e.g. svelte-rewrite)"
contents: write required: false
default: "main"
jobs: jobs:
frontend: frontend:
@@ -16,6 +17,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
@@ -46,6 +49,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Download frontend dist - name: Download frontend dist
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@@ -79,89 +84,60 @@ jobs:
shell: bash shell: bash
run: | run: |
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \ "https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
-o suwayomi-windows.zip -o suwayomi-windows.zip
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw unzip -q suwayomi-windows.zip -d suwayomi-raw
- name: Extract Suwayomi bundle TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d)
shell: bash TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f)
run: | TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
mkdir -p suwayomi-extracted mkdir -p suwayomi-extracted
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l) if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l) mv "$TOP_DIRS"/* suwayomi-extracted/
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
cp -r "$INNER"/. suwayomi-extracted/
else else
cp -r suwayomi-raw/. suwayomi-extracted/ mv suwayomi-raw/* suwayomi-extracted/
fi fi
- name: Stage Suwayomi bundle - name: Stage Suwayomi bundle
shell: bash shell: bash
run: | run: |
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
if [ -z "$JAVA" ]; then
echo "ERROR: jre/bin/java.exe not found"
find suwayomi-extracted -type f | head -50
exit 1
fi
if [ -z "$JAR" ]; then
echo "ERROR: Suwayomi-Server.jar not found"
find suwayomi-extracted -type f | head -50
exit 1
fi
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
- name: Validate staging JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
shell: bash
run: | if [ -z "$JAVAW" ]; then
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \ echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:"
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1) find suwayomi-extracted -type f | head -40
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \ exit 1
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1) fi
echo "Staging OK"
echo "Found javaw: $JAVAW"
# Copy full bundle so jar + jre tree are available at runtime.
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir.
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
- name: Patch tauri.conf.json for CI - name: Patch tauri.conf.json for CI
shell: bash shell: bash
run: | run: |
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
- name: Delete existing draft release if present - name: Build Tauri app (Windows x64)
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases" \
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
if [ -n "$RELEASE_ID" ]; then
echo "Deleting existing draft release $RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
echo "Deleted draft release and tag"
else
echo "No existing draft release found"
fi
- name: Build Tauri app + create draft release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tagName: v${{ github.event.inputs.version }} args: >-
releaseName: Moku v${{ github.event.inputs.version }} --target x86_64-pc-windows-msvc
releaseBody: | --config '{"bundle":{"resources":["binaries/suwayomi-bundle/**"]}}'
Moku v${{ github.event.inputs.version }}
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe` - name: Upload Windows installer
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg` uses: actions/upload-artifact@v4
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg` with:
**Linux:** Download `moku.flatpak` name: moku-windows-x64
releaseDraft: true path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
prerelease: false retention-days: 7
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
+8 -22
View File
@@ -1,33 +1,26 @@
# --- Build Artifacts ---
node_modules/ node_modules/
suwayomi-raw/
suwayomi-windows.zip
dist/ dist/
dist-tauri/ dist-tauri/
target/ target/
bin/ bin/
out/ out/
# --- Nix ---
.direnv/ .direnv/
result result
result-* result-*
# --- Logs ---
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log*
.env .env
.env.*
!.env.example
!.env.test
.env.local .env.local
.env.*.local .env.*.local
.output # --- IDEs & OS ---
.vercel
.netlify
.wrangler
/.svelte-kit
/build
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
@@ -37,19 +30,12 @@ yarn-debug.log*
*.sln *.sln
*.swp *.swp
# --- Tauri specific ---
src-tauri/target/ src-tauri/target/
src-tauri/binaries/
src-tauri/gen/ src-tauri/gen/
.DS_Store # --- Flatpak build artifacts ---
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
build-dir/ build-dir/
repo/ repo/
dist/
packaging/frontend-dist.tar.gz
*.flatpak *.flatpak
./flatpak-builder .flatpak-builder/
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [2026] [@Youwes09] Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+42 -46
View File
@@ -1,10 +1,10 @@
pkgname=moku pkgname=moku
pkgver=0.9.4 pkgver=0.3.0
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
url="https://github.com/moku-project/Moku" url="https://github.com/Youwes09/Moku"
license=('Apache-2.0') license=('Apache 2.0')
depends=( depends=(
'webkit2gtk-4.1' 'webkit2gtk-4.1'
'gtk3' 'gtk3'
@@ -13,46 +13,34 @@ depends=(
) )
makedepends=( makedepends=(
'rust' 'rust'
'cargo'
'nodejs'
'pnpm' 'pnpm'
) )
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar" "suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
) "jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=(
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
'SKIP'
'SKIP'
) )
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
prepare() { prepare() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
mkdir -p src-tauri/.cargo
cat > src-tauri/.cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
EOF
} }
build() { build() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
# Build frontend
pnpm build pnpm build
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
fixed_cflags="${fixed_cflags/-flto=auto/}" # Repack dist for Tauri
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}" tar -czf packaging/frontend-dist.tar.gz -C dist .
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
CFLAGS="$fixed_cflags" \ # Build Tauri binary
CXXFLAGS="$fixed_cxxflags" \
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \ TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \ --release \
--manifest-path src-tauri/Cargo.toml --manifest-path src-tauri/Cargo.toml
@@ -61,14 +49,21 @@ build() {
package() { package() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
# Moku binary
install -Dm755 src-tauri/target/release/moku \ install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku" "$pkgdir/usr/bin/moku"
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \ # Bundled JRE
install -dm755 "$pkgdir/usr/lib/moku/jre"
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
# Suwayomi server jar
install -Dm644 "$srcdir/suwayomi-server.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar" "$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
# tachidesk-server wrapper script
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf" install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF' cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = false
@@ -79,11 +74,11 @@ server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12 server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6 server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
CONF EOF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER' install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
if [ ! -f "$DATA_DIR/server.conf" ]; then if [ ! -f "$DATA_DIR/server.conf" ]; then
@@ -105,25 +100,26 @@ unset WAYLAND_DISPLAY
export _JAVA_OPTIONS="-Djava.awt.headless=true" export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec java \ exec /usr/lib/moku/jre/bin/java \
-Djava.awt.headless=true \ -Djava.awt.headless=true \
-Dapple.awt.UIElement=true \ -Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \ -Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \ -Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \ -Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
LAUNCHER EOF
install -Dm644 packaging/io.github.moku_project.Moku.desktop \ # Desktop entry and icons
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop" install -Dm644 packaging/dev.moku.app.desktop \
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
install -Dm644 src-tauri/icons/32x32.png \ install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
install -Dm644 src-tauri/icons/128x128.png \ install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
install -Dm644 src-tauri/icons/128x128@2x.png \ install -Dm644 src-tauri/icons/128x128@2x.png \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \ install -Dm644 packaging/dev.moku.app.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml" "$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
install -Dm644 LICENSE \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+126 -25
View File
@@ -1,42 +1,143 @@
# sv <div align="center">
<img src="src/assets/rounded-logo.png" width="96" />
<h1>Moku</h1>
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). <table>
<tr>
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
</tr>
<tr>
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
</tr>
</table>
</div>
## Creating a project ---
If you're seeing this, you've probably already done this step. Congrats! ## Features
```sh ### Reader
# create a new project - **Single**, **double-page**, and **longstrip** reading modes
npx sv create my-app - **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
- Fit modes: fit width, fit height, fit screen, and 1:1 original
- Per-series zoom control via Ctrl+scroll or a slider popover
- RTL / LTR reading direction toggle
- Configurable page gaps
- Full keyboard navigation with rebindable keybinds
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
- Chapter-relative page counter that updates live as you scroll through the infinite strip
- Auto-mark chapters as read when the last page is reached
### Library
- Grid view of your entire manga collection with lazy-loaded cover art
- Filter tabs: **Saved**, **Downloaded**, and **All**
- Genre tag filter chips — multi-select to narrow by any combination of tags
- In-line search
- Context menu: open, add/remove from library
### Series Detail
- Cover, author, artist, status badge, genres, and synopsis
- Read progress bar with percentage
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
- Chapter list with scanlator, upload date, and in-progress page indicator
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
- Sort by newest or oldest first
- Jump-to-chapter input
- Bulk download menu: from current chapter, unread only, or all
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
- Collapsible source details panel with source ID, language, and source migration
### Search
- Cross-source search running up to 3 concurrent requests
- Language filter bar (preferred language default, per-language, or all)
- Results grouped by source with skeleton loading states
### Sources & Extensions
- Browse and search installed sources, grouped by extension with per-language expansion
- Extension manager: install, update, remove, and install from external APK URL
- Repo refresh with update count badge
### Downloads
- Download queue with live progress
### History
- Reading history grouped by day with relative timestamps
- Per-entry thumbnail, chapter name, and last-read page
- Full-text search across titles and chapter names
- One-click clear
---
## Requirements
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
---
## Installation
**Nix (recommended)**
```bash
nix run github:Youwes09/moku
``` ```
To recreate this project with the same configuration: Add to your flake:
```sh ```nix
# recreate this project inputs.moku.url = "github:Youwes09/moku";
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
``` ```
## Developing **From source**
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ```bash
git clone https://github.com/Youwes09/moku
```sh cd moku
npm run dev nix build
./result/bin/moku
# or start the server and open the app in a new browser tab
npm run dev -- --open
``` ```
## Building ---
To create a production version of your app: ## Development
```sh ```bash
npm run build nix develop
pnpm install
pnpm tauri:dev
``` ```
You can preview the production build with `npm run preview`. > `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ---
## Stack
| | |
|---|---|
| [Tauri v2](https://tauri.app) | Native app shell |
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler |
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
---
## License
Distributed under the [Apache 2.0 License](./LICENSE).
---
## Disclaimer
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+95 -30
View File
@@ -1,39 +1,104 @@
Major Revisions: Todo:
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility) 3. Explore Manga Upscaler & Other Image Processing
- Moku-Share allows exporting of Manga 4. Font Weird on Flatpak, Investigate and Fix
- Compressed Format (Storage) 5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
Priority Bugs: Bugs:
- Fix Library-Refresh System (TESTING)
- Suwayomi RESET - Add Back after Search & Clear on Search
- Allow User to Wipe Suwayomi (Scratch) - Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
- If Possible, Component based Wipe (Library, Etc) - Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
Pending/On-Hold:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Change Auto-Link Threshold - Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
- Fix Auto-Link De-dupe for Images - - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
- Optimize Auto-Link Latency (IP) skip chapters, etc
- Mark as Read no longer working on select chapters, choose more robust methodology.
- Reset to top when user clicks next chapter in reader.
In-Progress:
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Apply Syer's Fix for Library on Backup Load (Manga Metadata) - Fix Downloaded in Library (Tags Broken) & All
- Note User's have to always install extensions manually - Using Delete All Crashes App (But Works)
- Create "Missing Source" for Manga - Fix Folder Display in Library
- Add Version Tags (To Find Version)
- Sidebar Icon Highlighted
- Introduce Deduplication into Library & Search
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
- UI LOGIN DOES NOT WORK OFFLINE Features:
Notes from last time: - Add PDF Textbook Support
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Migration Features
- Multi-Page Long Screenshot
- Add Consumet Api (Anime & Light Novel Support)
Big Revisions:
0. Expand into fully-fledged reader, with modular manga support
1. Anime & Novel Support
2. Tracker Support
3. Cloudflare Bypass Enable Support
4. macOS Support (feasible)
Testing:
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
- Fix the Mark as Read (Glitched)
Completed:
8. Fix Polling on Download Manager (Instantanous Response)
19. Debounce Time on Reader to improve lag (Toggle Setting)
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
17. Change Library Text change to "No manga saved to library, browse sources to add some."
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
7. Fix Scaling (100 = 125% and so forth)
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
11. Reader & UI needs download and other Notifications
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
- Add Refresh Details on Series Details.
- Patch GenreDrill & Integrate into Explore Folder
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
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
- Properly Kill Tachidesk-Server
- Fix scaling on splash screen
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
3. flatpak build-bundle repo moku.flatpak dev.moku.app
-364
View File
@@ -1,364 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { applyTheme } from "@core/theme";
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
import { checkForUpdateSilently } from "@core/updater";
import Layout from "@shared/chrome/Layout.svelte";
import Reader from "@features/reader/components/Reader.svelte";
import Settings from "@features/settings/components/Settings.svelte";
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
import TitleBar from "@shared/chrome/TitleBar.svelte";
import Toaster from "@shared/chrome/Toaster.svelte";
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
import MangaPreview from "@shared/manga/MangaPreview.svelte";
import AuthGate from "@shared/chrome/AuthGate.svelte";
const win = getCurrentWindow();
void platform();
let appReady = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
let closeDialogOpen = $state(false);
let closeRemember = $state(false);
function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null;
themeEditorOpen = true;
}
function closeThemeEditor() {
themeEditorOpen = false;
themeEditorEditId = null;
}
async function doQuit() {
if (store.settings.autoStartServer) {
await Promise.race([
invoke("kill_server").catch(() => {}),
new Promise(res => setTimeout(res, 2000)),
]);
}
await invoke("exit_app");
}
async function doHide() {
await win.hide();
}
async function handleCloseRequested() {
const action = store.settings.closeAction ?? "ask";
if (action === "tray") { await doHide(); return; }
if (action === "quit") { await doQuit(); return; }
closeDialogOpen = true;
}
async function confirmClose(choice: "tray" | "quit") {
closeDialogOpen = false;
if (closeRemember) updateSettings({ closeAction: choice });
closeRemember = false;
if (choice === "tray") await doHide();
else await doQuit();
}
$effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey());
$effect(() => {
if (!appReady) return;
return mountIdleDetection(
() => { idle = true; },
() => { if (idle) idle = false; },
);
});
$effect(() => {
if (!appReady) return;
const timer = setTimeout(checkForUpdateSilently, 5_000);
return () => clearTimeout(timer);
});
$effect(() => {
if (!appReady) return;
downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(dlInterval);
});
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
$effect(() => {
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
$effect(() => {
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => { devSplash = true; };
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async () => {
applyZoom();
});
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
await initStore();
startProbe();
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", {
binary: store.settings.serverBinary,
webUiEnabled: store.settings.suwayomiWebUI ?? false,
}).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
}
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress",
e => setActiveDownloads(e.payload),
);
return () => {
stopProbe();
unlistenResize();
unlistenScale();
unlistenDownload();
unlistenClose();
destroyRpc();
delete (window as any).__mokuShowSplash;
};
});
</script>
{#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady && !boot.loginRequired}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => { appReady = true; }}
onRetry={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { appReady = true; }} />
{:else}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; }} />
{/if}
{#if boot.sessionExpired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
{/if}
<div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
<div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
{#if themeEditorOpen}
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
{/if}
<MangaPreview />
<Toaster />
</div>
{/if}
{#if closeDialogOpen}
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="close-header">
<p class="close-title">Close Moku?</p>
<p class="close-sub">Choose how the app should exit.</p>
</div>
<div class="close-actions">
<button class="close-btn" onclick={() => confirmClose("tray")}>
<span class="close-btn-label">Minimize to Tray</span>
<span class="close-btn-desc">Keep running in the background</span>
</button>
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
<span class="close-btn-label">Quit</span>
<span class="close-btn-desc">Stop Moku entirely</span>
</button>
</div>
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
<span class="close-remember-label">Remember my choice</span>
</button>
</div>
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
.close-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.close-dialog {
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-2xl);
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-3);
width: 300px;
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 20px 60px rgba(0,0,0,0.65),
0 6px 20px rgba(0,0,0,0.35);
}
.close-header { display: flex; flex-direction: column; gap: 3px; }
.close-title {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
margin: 0;
}
.close-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
.close-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 10px var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
cursor: pointer;
text-align: left;
transition: background var(--t-base), border-color var(--t-base);
}
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
.close-btn-danger .close-btn-label { color: var(--color-error); }
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
.close-btn-label {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.close-btn-desc {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.close-remember {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0 0;
background: none;
border: none;
cursor: pointer;
user-select: none;
}
.close-remember-toggle {
position: relative;
width: 28px;
height: 16px;
border-radius: var(--radius-full);
border: 1px solid var(--border-strong);
background: var(--bg-overlay);
flex-shrink: 0;
transition: background var(--t-base), border-color var(--t-base);
}
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
.close-remember-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.close-remember-toggle.on .close-remember-thumb {
transform: translateX(12px);
background: var(--bg-void);
}
.close-remember-label {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
</style>
-151
View File
@@ -1,151 +0,0 @@
import { store } from "@store/state.svelte";
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
import { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache";
const DEFAULT_URL = "http://127.0.0.1:4567";
type ReauthResolver = () => void;
let _reauthQueue: ReauthResolver[] = [];
export function notifyReauthSuccess() {
const queue = _reauthQueue;
_reauthQueue = [];
queue.forEach(resolve => resolve());
}
function waitForReauth(): Promise<void> {
return new Promise(resolve => { _reauthQueue.push(resolve); });
}
export function getServerUrl(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
}
export function plainThumbUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
}
export async function resolveImageUrl(path: string): Promise<string> {
if (!path) return "";
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return url;
return getBlobUrl(url);
}
export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> {
data: T;
errors?: { message: string }[];
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
async function fetchWithRetry(
url: string,
init: RequestInit,
signal?: AbortSignal,
retries = 3,
delayMs = 300,
): Promise<Response> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e: any) {
if (e?.authRequired) throw e;
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (e instanceof AuthRequiredError) throw e;
if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
}
throw new Error("unreachable");
}
export async function fetchImage(
path: string,
signal?: AbortSignal,
): Promise<{ src: string; revoke: () => void }> {
if (!path) return { src: "", revoke: () => {} };
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "NONE") return { src: url, revoke: () => {} };
const res = await fetchWithRetry(url, { method: "GET" }, signal);
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
const blob = await res.blob();
const src = URL.createObjectURL(blob);
return { src, revoke: () => URL.revokeObjectURL(src) };
}
export async function gql<T>(
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const tryRefreshAndRetry = async (): Promise<T | null> => {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode !== "UI_LOGIN" || boot.skipped) return null;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return null;
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
};
const attempt = async (): Promise<T> => {
const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
signal,
);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
}
throw new Error(`Suwayomi HTTP ${res.status}`);
}
const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) {
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
boot.sessionExpired = true;
boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? "";
await waitForReauth();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return attempt();
}
throw new Error(json.errors[0].message);
}
return json.data;
};
return attempt();
}
-11
View File
@@ -1,11 +0,0 @@
export * from "./client";
export * from "./queries/manga";
export * from "./queries/chapters";
export * from "./queries/downloads";
export * from "./queries/extensions";
export * from "./queries/tracking";
export * from "./mutations/manga";
export * from "./mutations/chapters";
export * from "./mutations/downloads";
export * from "./mutations/extensions";
export * from "./mutations/tracking";
-64
View File
@@ -1,64 +0,0 @@
export const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`;
export const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`;
export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`;
export const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`;
export const UPDATE_CHAPTERS_PROGRESS = `
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
chapters { id isRead isBookmarked lastPageRead }
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = `
mutation DeleteDownloadedChapters($ids: [Int!]!) {
deleteDownloadedChapters(input: { ids: $ids }) {
chapters { id isDownloaded }
}
}
`;
export const SET_CHAPTER_META = `
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_CHAPTER_META = `
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
meta { key value }
}
}
`;
-99
View File
@@ -1,99 +0,0 @@
const QUEUE_FRAGMENT = `
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
`;
export const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
enqueueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`;
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
dequeueChapterDownloads(input: { ids: $chapterIds }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const REORDER_DOWNLOAD = `
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const START_DOWNLOADER = `
mutation StartDownloader {
startDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const STOP_DOWNLOADER = `
mutation StopDownloader {
stopDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { ${QUEUE_FRAGMENT} }
}
}
`;
export const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`;
export const SET_DOWNLOADS_PATH = `
mutation SetDownloadsPath($path: String!) {
setSettings(input: { settings: { downloadsPath: $path } }) {
settings { downloadsPath }
}
}
`;
export const SET_LOCAL_SOURCE_PATH = `
mutation SetLocalSourcePath($path: String!) {
setSettings(input: { settings: { localSourcePath: $path } }) {
settings { localSourcePath }
}
}
`;
-215
View File
@@ -1,215 +0,0 @@
export const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const UPDATE_EXTENSIONS = `
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extensions { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) {
extension { apkName pkgName name isInstalled }
}
}
`;
export const UPDATE_SOURCE_PREFERENCE = `
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
updateSourcePreference(input: { source: $source, change: $change }) {
source { id displayName }
}
}
`;
export const SET_SOURCE_METAS = `
mutation SetSourceMetas($input: SetSourceMetasInput!) {
setSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const DELETE_SOURCE_METAS = `
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
deleteSourceMetas(input: $input) {
metas { sourceId key value }
}
}
`;
export const UPDATE_SOURCE_METADATA = `
mutation UpdateSourceMetadata(
$preUpdateDeleteInput: DeleteSourceMetasInput!
$hasPreUpdateDeletions: Boolean!
$updateInput: SetSourceMetasInput!
$hasUpdates: Boolean!
$postUpdateDeleteInput: DeleteSourceMetasInput!
$hasPostUpdateDeletions: Boolean!
$migrateInput: SetSourceMetasInput!
$isMigration: Boolean!
) {
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
metas { sourceId key value }
}
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
metas { sourceId key value }
}
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
metas { sourceId key value }
}
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
metas { sourceId key value }
}
}
`;
export const SET_SOURCE_META = `
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_SOURCE_META = `
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
meta { key value }
}
}
`;
export const SET_CATEGORY_META = `
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_CATEGORY_META = `
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
meta { key value }
}
}
`;
export const SET_GLOBAL_META = `
mutation SetGlobalMeta($key: String!, $value: String!) {
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_GLOBAL_META = `
mutation DeleteGlobalMeta($key: String!) {
deleteGlobalMeta(input: { key: $key }) {
meta { key value }
}
}
`;
export const CLEAR_CACHED_IMAGES = `
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
clearCachedImages(input: {
cachedPages: $cachedPages
cachedThumbnails: $cachedThumbnails
downloadedThumbnails: $downloadedThumbnails
}) {
cachedPages cachedThumbnails downloadedThumbnails
}
}
`;
export const RESET_SETTINGS = `
mutation ResetSettings {
resetSettings(input: {}) {
settings { extensionRepos }
}
}
`;
export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) {
settings { extensionRepos }
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings { authMode authUsername }
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
}
`;
-5
View File
@@ -1,5 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
-153
View File
@@ -1,153 +0,0 @@
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`;
export const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`;
export const UPDATE_MANGAS = `
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
mangas { id inLibrary }
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga { id }
}
}
`;
export const UPDATE_MANGAS_CATEGORIES = `
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
mangas { id }
}
}
`;
export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) {
category { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category { id name order }
}
}
`;
export const UPDATE_CATEGORIES = `
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
updateCategories(input: { ids: $ids, patch: $patch }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) {
category { id }
}
}
`;
export const UPDATE_CATEGORY_ORDER = `
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
updateCategoryOrder(input: { id: $id, position: $position }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY_MANGA = `
mutation UpdateCategoryManga($categoryId: Int!) {
updateCategoryManga(input: { categoryId: $categoryId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_LIBRARY_MANGA = `
mutation UpdateLibraryManga($mangaId: Int!) {
updateLibraryManga(input: { mangaId: $mangaId }) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const UPDATE_STOP = `
mutation UpdateStop {
updateStop(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`;
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id
status { mangaProgress state totalManga }
}
}
`;
export const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
meta { key value }
}
}
`;
export const DELETE_MANGA_META = `
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
meta { key value }
}
}
`;
-130
View File
@@ -1,130 +0,0 @@
# Mutations
## Manga (`mutations/manga.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
| `UPDATE_STOP` | — | Stop the currently running library update job |
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
---
## Chapters (`mutations/chapters.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
---
## Downloads (`mutations/downloads.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
| `START_DOWNLOADER` | — | Start the downloader |
| `STOP_DOWNLOADER` | — | Stop the downloader |
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
---
## Extensions (`mutations/extensions.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
---
## Tracking (`mutations/tracking.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
| `REFRESH_TOKEN` | — | Refresh the current access token |
---
## New in Preview
Mutations now available and not yet wired to any feature in Moku:
| Mutation | Potential Feature |
|----------|-------------------|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
| `UPDATE_STOP` | Cancel button for library update jobs |
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
| `RESET_SETTINGS` | Settings page — factory reset button |
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
-127
View File
@@ -1,127 +0,0 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
`;
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { ${TRACK_RECORD_FRAGMENT} }
}
}
`;
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
}
}
}
`;
export const UNBIND_TRACK = `
mutation UnbindTrack($recordId: Int!) {
unbindTrack(input: { recordId: $recordId }) {
trackRecord { id }
}
}
`;
export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
}
}
}
`;
export const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords {
id trackerId lastChapterRead status
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const CONNECT_KOSYNC = `
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
isConnected
}
}
`;
export const LOGOUT_KOSYNC = `
mutation LogoutKoSync {
logoutKoSyncAccount(input: {}) {
isConnected
}
}
`;
export const PULL_KOSYNC_PROGRESS = `
mutation PullKoSyncProgress($chapterId: Int!) {
pullKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const PUSH_KOSYNC_PROGRESS = `
mutation PushKoSyncProgress($chapterId: Int!) {
pushKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
accessToken
refreshToken
clientMutationId
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
}
`;
-28
View File
@@ -1,28 +0,0 @@
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes {
id
name
chapterNumber
sourceOrder
isRead
lastPageRead
mangaId
fetchedAt
manga { id title thumbnailUrl inLibrary }
}
}
}
`;
export const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`;
-14
View File
@@ -1,14 +0,0 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
-117
View File
@@ -1,117 +0,0 @@
export const GET_LOCAL_MANGA = `
query GetLocalManga {
mangas(condition: { sourceId: "0" }) {
nodes { id title thumbnailUrl inLibrary }
}
}
`;
export const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const GET_SOURCES = `
query GetSources {
sources {
nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest
extension { pkgName }
}
}
}
`;
export const GET_SOURCE_SETTINGS = `
query GetSourceSettings($id: LongString!) {
source(id: $id) {
id
displayName
preferences {
... on CheckBoxPreference {
type: __typename
CheckBoxTitle: title
CheckBoxSummary: summary
CheckBoxDefault: default
CheckBoxCurrentValue: currentValue
key
}
... on SwitchPreference {
type: __typename
SwitchPreferenceTitle: title
SwitchPreferenceSummary: summary
SwitchPreferenceDefault: default
SwitchPreferenceCurrentValue: currentValue
key
}
... on ListPreference {
type: __typename
ListPreferenceTitle: title
ListPreferenceSummary: summary
ListPreferenceDefault: default
ListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
... on EditTextPreference {
type: __typename
EditTextPreferenceTitle: title
EditTextPreferenceSummary: summary
EditTextPreferenceDefault: default
EditTextPreferenceCurrentValue: currentValue
dialogTitle
dialogMessage
key
}
... on MultiSelectListPreference {
type: __typename
MultiSelectListPreferenceTitle: title
MultiSelectListPreferenceSummary: summary
MultiSelectListPreferenceDefault: default
MultiSelectListPreferenceCurrentValue: currentValue
entries
entryValues
key
}
}
}
}
`;
export const GET_MIGRATABLE_SOURCES = `
query GetMigratableSources {
mangas(condition: { inLibrary: true }) {
nodes {
sourceId
source {
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
}
}
}
}
`;
export const GET_SETTINGS = `
query GetSettings {
settings { extensionRepos }
}
`;
export const GET_SERVER_SECURITY = `
query GetServerSecurity {
settings {
authMode authUsername
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
`;
-7
View File
@@ -1,7 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
export * from "./updater";
export * from "./meta";
-110
View File
@@ -1,110 +0,0 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
source { id name displayName }
chapters { totalCount }
latestFetchedChapter { id uploadDate }
latestUploadedChapter { id uploadDate }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
}
}
`;
export const GET_ALL_MANGA = `
query GetAllManga {
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount }
}
}
`;
export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
}
}
`;
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id name order default includeInUpdate includeInDownload
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
}
}
}
}
`;
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
query GetDownloadedChaptersPages {
chapters(condition: { isDownloaded: true }) {
nodes { pageCount }
}
}
`;
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`;
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo {
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
}
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
lastUpdateTimestamp {
timestamp
}
}
`;
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`;
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`;
export 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
}
}
`;
-15
View File
@@ -1,15 +0,0 @@
export const GET_META = `
query GetMeta($key: String!) {
meta(key: $key) {
key value
}
}
`;
export const GET_METAS = `
query GetMetas {
metas {
nodes { key value }
}
}
`;
-117
View File
@@ -1,117 +0,0 @@
# Queries
## Manga (`queries/manga.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
---
## Chapters (`queries/chapters.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
---
## Downloads (`queries/downloads.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
---
## Extensions (`queries/extensions.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
| `GET_SETTINGS` | — | `extensionRepos` from settings |
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
---
## Tracking (`queries/tracking.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
---
## Updater (`queries/updater.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
---
## Meta (`queries/meta.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
| `GET_METAS` | — | All global meta entries as a node list |
---
## KoSync (`queries/kosync.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
---
## New in Preview
Queries and fields now available but not yet wired to any feature in Moku:
| Query / Field | Potential Feature |
|---------------|-------------------|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
| `category` (single by id) | Direct category detail without fetching all categories |
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
| `source` (single by id) | Source detail page — preferences, filters, browse |
| `tracker` (single by id) | Individual tracker detail — statuses, records |
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
-71
View File
@@ -1,71 +0,0 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name }
}
}
}
`;
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
}
}
}
}
`;
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`;
export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private libraryId
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`;
export const GET_TRACKER_RECORDS = `
query GetTrackerRecords($trackerId: Int!) {
trackers(condition: { id: $trackerId }) {
nodes {
id name
statuses { value name }
trackRecords {
nodes {
id title status displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl }
}
}
}
}
}
`;
-23
View File
@@ -1,23 +0,0 @@
export const GET_ABOUT_SERVER = `
query GetAboutServer {
aboutServer {
name version buildType buildTime github discord
}
}
`;
export const GET_ABOUT_WEBUI = `
query GetAboutWebUI {
aboutWebUI {
channel tag updateTimestamp
}
}
`;
export const CHECK_FOR_SERVER_UPDATES = `
query CheckForServerUpdates {
checkForServerUpdates {
channel tag url
}
}
`;
-1
View File
@@ -1 +0,0 @@
export * from "./selectPortal";
-38
View File
@@ -1,38 +0,0 @@
import type { Attachment } from "svelte/attachments";
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const r = triggerEl.getBoundingClientRect();
const top = r.bottom / zoom + 4;
const right = r.right / zoom;
const width = menuEl.offsetWidth;
const left = Math.max(8, right - width);
menuEl.style.position = "fixed";
menuEl.style.top = `${top}px`;
menuEl.style.left = `${left}px`;
}
menuEl.style.visibility = "hidden";
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
requestAnimationFrame(() => {
position();
menuEl.style.visibility = "";
});
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, true);
window.removeEventListener("resize", position);
triggerEl.__selectMenuEl = null;
menuEl.remove();
};
};
}
-5
View File
@@ -1,5 +0,0 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
}
-5
View File
@@ -1,5 +0,0 @@
export * from './sort';
export * from './filter';
export * from './paginate';
export * from './search';
export * from './queue';
-29
View File
@@ -1,29 +0,0 @@
export interface PaginationState {
visible: number;
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
}
export function createPaginator<T>(pageSize: number) {
return {
slice(all: T[], visible: number): PaginationResult<T> {
return {
items: all.slice(0, visible),
hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible),
};
},
nextVisible(current: number): number {
return current + pageSize;
},
reset(): number {
return pageSize;
},
};
}
-29
View File
@@ -1,29 +0,0 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
-33
View File
@@ -1,33 +0,0 @@
export interface SearchResult<T> {
item: T;
score: number;
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
-32
View File
@@ -1,32 +0,0 @@
export type SortDir = "asc" | "desc";
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
}
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
return {
sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
if (!f) return [...items];
const d = dir ?? config.defaultDir;
return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context);
return d === "asc" ? cmp : -cmp;
});
},
};
}
-61
View File
@@ -1,61 +0,0 @@
/**
* Runs an async task over every item in `items`, with at most `concurrency`
* tasks in-flight at once. Respects the provided AbortSignal — each worker
* exits early if the signal fires. Errors thrown by individual tasks are
* swallowed so one failure does not cancel the whole batch.
*/
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, worker),
);
}
/**
* Deduplicates in-flight async calls by key.
*
* Two call signatures are supported:
*
* 1. Direct call — supply a key and a zero-arg factory each time:
* dedupeRequest("my-key", () => fetchSomething())
* If a request with that key is already pending, the existing Promise is
* returned and the factory is not called again.
*
* 2. Curried wrapper — supply a key-based fetcher once, get back a
* single-arg function you can call repeatedly:
* const get = dedupeRequest((key) => fetchSomething(key))
* get("my-key")
*/
const _inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
// Curried wrapper form
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
// Direct call form
const key = keyOrFn;
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
const p = factory!().finally(() => _inflight.delete(key));
_inflight.set(key, p);
return p;
}
-25
View File
@@ -1,25 +0,0 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
}
export function createPaginatedQuery<T>(
config: PaginatedQueryConfig<T>,
): PaginatedQuery<T> {
let _hasMore = true;
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page);
_hasMore = hasNextPage;
return items;
},
reset() { _hasMore = true; },
hasMore() { return _hasMore; },
};
}
-31
View File
@@ -1,31 +0,0 @@
export interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean;
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
-3
View File
@@ -1,3 +0,0 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
-629
View File
@@ -1,629 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
export class AuthRequiredError extends Error {
constructor(msg = "Authentication required") {
super(msg);
this.name = "AuthRequiredError";
}
}
const TOKEN_KEY = "moku_access_token";
const UI_SESSION_KEY = "moku_ui_auth_session";
const TOKEN_REFRESH_SKEW_MS = 30_000;
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
interface StoredAccessToken {
base: string;
token: string;
}
interface StoredUiAuthSession {
base: string;
accessToken: string;
refreshToken?: string;
clientMutationId?: string;
accessExpiresAt?: number | null;
refreshExpiresAt?: number | null;
}
interface JwtSettings {
jwtAudience?: string | null;
jwtRefreshExpiry?: string | null;
jwtTokenExpiry?: string | null;
}
export interface UiAuthDebugStatus {
mode: AuthMode;
serverBase: string;
hasSession: boolean;
hasRefreshToken: boolean;
accessExpiresAt: number | null;
refreshExpiresAt: number | null;
accessExpiresInMs: number | null;
refreshExpiresInMs: number | null;
shouldRefreshSoon: boolean;
refreshInFlight: boolean;
skewMs: number;
}
let _accessToken: string | null = null;
let _accessTokenBase: string | null = null;
let _uiSession: StoredUiAuthSession | null = null;
let _refreshPromise: Promise<string | null> | null = null;
let _jwtSettingsBase: string | null = null;
let _jwtSettings: JwtSettings | null = null;
let _jwtSettingsFetchedAt = 0;
function authDebug(event: string, fields?: Record<string, unknown>) {
if (!AUTH_DEBUG) return;
if (fields) {
console.debug(`[auth] ${event}`, fields);
return;
}
console.debug(`[auth] ${event}`);
}
function parseIsoDuration(duration: string): number | null {
try {
const match = duration.match(
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
);
if (!match) return null;
const [, years, months, days, hours, minutes, seconds] = match;
let ms = 0;
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
if (minutes) ms += parseInt(minutes) * 60 * 1000;
if (seconds) ms += parseFloat(seconds) * 1000;
return ms;
} catch {
return null;
}
}
function decodeJwtExpiryMs(token: string): number | null {
try {
const payload = token.split(".")[1];
if (!payload) return null;
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
const decoded = atob(padded);
const json = JSON.parse(decoded) as { exp?: number };
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
return Date.now() >= expiresAt - skewMs;
}
function withExpiryFromSettings(
accessToken: string,
jwt: JwtSettings | null,
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
const now = Date.now();
const accessExpiresAt =
decodeJwtExpiryMs(accessToken)
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
const refreshExpiresAt =
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
return { accessExpiresAt, refreshExpiresAt };
}
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
const res = await fetchAuthenticated(
`${base}/api/graphql`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`query GetJWTSettings {
settings {
jwtAudience
jwtRefreshExpiry
jwtTokenExpiry
}
}`,
),
},
timeoutSignal(5000),
);
if (!res.ok) {
authDebug("JWT settings fetch failed", { status: res.status });
return null;
}
const json = await res.json();
if (json?.errors?.length) {
authDebug("JWT settings query error", { errors: json.errors });
return null;
}
const settings = json?.data?.settings;
if (!settings || typeof settings !== "object") {
authDebug("JWT settings missing or invalid", { settings });
return null;
}
authDebug("JWT settings fetched", {
hasAudience: !!settings.jwtAudience,
tokenExpiry: settings.jwtTokenExpiry,
refreshExpiry: settings.jwtRefreshExpiry,
});
return {
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
};
}
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
const base = getServerBase();
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
const jwt = await fetchJwtSettings(base);
_jwtSettingsBase = base;
_jwtSettings = jwt;
_jwtSettingsFetchedAt = Date.now();
return jwt;
}
export const uiAuth = {
getSession: () => {
const base = getServerBase();
if (_uiSession && _uiSession.base === base) return _uiSession;
const stored = readStoredSession();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(UI_SESSION_KEY);
sessionStorage.removeItem(TOKEN_KEY);
_uiSession = null;
_accessToken = null;
_accessTokenBase = null;
return null;
}
_uiSession = stored;
_accessToken = stored.accessToken;
_accessTokenBase = stored.base;
return _uiSession;
},
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
const base = getServerBase();
_uiSession = { ...session, base };
_accessToken = session.accessToken;
_accessTokenBase = base;
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
sessionStorage.removeItem(TOKEN_KEY);
},
getToken: () => {
const session = uiAuth.getSession();
if (!session) return null;
if (isExpired(session.accessExpiresAt, 0)) return null;
const base = getServerBase();
if (_accessToken && _accessTokenBase === base) return _accessToken;
const stored = readStoredToken();
if (!stored) return null;
if (stored.base !== base) {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
return null;
}
_accessToken = stored.token;
_accessTokenBase = stored.base;
return _accessToken;
},
setToken: (t: string) => {
const existing = uiAuth.getSession();
if (existing?.refreshToken) {
uiAuth.setSession({
...existing,
accessToken: t,
...withExpiryFromSettings(t, _jwtSettings),
});
return;
}
const base = getServerBase();
_accessToken = t;
_accessTokenBase = base;
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
},
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
uiAuth.setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
clientMutationId: payload.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
});
},
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
const existing = uiAuth.getSession();
if (!existing?.refreshToken) {
uiAuth.setToken(payload.accessToken);
return;
}
uiAuth.setSession({
...existing,
accessToken: payload.accessToken,
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
...withExpiryFromSettings(payload.accessToken, jwt),
refreshToken: existing.refreshToken,
});
},
clearToken: () => {
_accessToken = null;
_accessTokenBase = null;
_uiSession = null;
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
},
};
export const authSession = {
clearTokens() {
_refreshPromise = null;
_jwtSettings = null;
_jwtSettingsBase = null;
_jwtSettingsFetchedAt = 0;
uiAuth.clearToken();
},
hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
return true;
},
};
function getServerBase(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
}
function readStoredToken(): StoredAccessToken | null {
const session = readStoredSession();
if (session) return { base: session.base, token: session.accessToken };
const raw = sessionStorage.getItem(TOKEN_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
return { base: parsed.base, token: parsed.token };
} catch {}
const migrated = { base: getServerBase(), token: raw.trim() };
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
return migrated;
}
return null;
}
function readStoredSession(): StoredUiAuthSession | null {
const raw = sessionStorage.getItem(UI_SESSION_KEY);
if (raw?.trim()) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
return {
base: parsed.base,
accessToken: parsed.accessToken,
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
};
}
} catch {}
}
const legacy = sessionStorage.getItem(TOKEN_KEY);
if (!legacy?.trim()) return null;
try {
const parsed = JSON.parse(legacy);
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
} catch {}
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
return migrated;
}
function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
}
function bearerHeader(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function gqlBody(query: string, variables?: Record<string, unknown>): string {
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
}
export async function fetchAuthenticated(
url: string,
init: RequestInit,
signal?: AbortSignal,
skipped = false,
): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
});
}
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError();
}
let res = await fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) },
});
if (res.status !== 401 || skipped) return res;
const refreshed = await refreshUiAccessToken(true);
if (!refreshed) return res;
res = await fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
});
return res;
}
return fetch(url, { ...init, signal, credentials: "omit" });
}
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (forceRefresh || isExpired(session.accessExpiresAt)) {
return refreshUiAccessToken(true);
}
return session.accessToken;
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
const session = uiAuth.getSession();
if (!session) return null;
if (!session.refreshToken) {
if (force && isExpired(session.accessExpiresAt, 0)) return null;
return session.accessToken;
}
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
if (isExpired(session.refreshExpiresAt)) {
authDebug("refresh skipped: refresh token expired", {
force,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
uiAuth.clearToken();
return null;
}
if (_refreshPromise) {
authDebug("refresh joined existing request");
return _refreshPromise;
}
authDebug("refresh start", {
force,
accessExpiresAt: session.accessExpiresAt ?? null,
refreshExpiresAt: session.refreshExpiresAt ?? null,
});
_refreshPromise = (async () => {
const base = getServerBase();
const jwt = await getJwtSettings().catch(() => null);
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
}`,
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
),
signal: timeoutSignal(5000),
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
authDebug("refresh rejected by server", { status: res.status });
uiAuth.clearToken();
return null;
}
authDebug("refresh failed with HTTP error", { status: res.status });
throw new Error(`Token refresh failed (${res.status})`);
}
const json = await res.json();
const refreshed = json?.data?.refreshToken;
const nextAccessToken: string | undefined = refreshed?.accessToken;
if (!nextAccessToken) {
const msg = json?.errors?.[0]?.message;
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
authDebug("refresh rejected by GraphQL error", { message: msg });
uiAuth.clearToken();
return null;
}
authDebug("refresh returned no access token", { message: msg ?? null });
throw new Error(msg ?? "Token refresh failed");
}
uiAuth.updateAccessToken(
{
accessToken: nextAccessToken,
clientMutationId: typeof refreshed?.clientMutationId === "string"
? refreshed.clientMutationId
: session.clientMutationId,
},
jwt,
);
authDebug("refresh success", {
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
});
return nextAccessToken;
})()
.catch((e: unknown) => {
authDebug("refresh threw error", {
message: e instanceof Error ? e.message : String(e),
});
throw e;
})
.finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
const session = uiAuth.getSession();
const accessExpiresAt = session?.accessExpiresAt ?? null;
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
return {
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
serverBase: getServerBase(),
hasSession: !!session,
hasRefreshToken: !!session?.refreshToken,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
shouldRefreshSoon: isExpired(accessExpiresAt),
refreshInFlight: _refreshPromise !== null,
skewMs: TOKEN_REFRESH_SKEW_MS,
};
}
export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken
refreshToken
clientMutationId
}
}`,
{ username: user, password: pass },
),
signal: timeoutSignal(8000),
});
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json();
const payload = json?.data?.login;
const accessToken: string | undefined = payload?.accessToken;
const refreshToken: string | undefined = payload?.refreshToken;
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
authDebug("login success", { user });
const preliminarySession = {
accessToken,
refreshToken,
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
};
uiAuth.setLoginSession(preliminarySession, null);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
const jwt = await getJwtSettings(true).catch(() => null);
uiAuth.setLoginSession(preliminarySession, jwt);
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000),
});
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
}
export async function logout(): Promise<void> {
uiAuth.clearToken();
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
}
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
if (mode === "UI_LOGIN" && !token) return "auth_required";
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass));
} else if (mode === "UI_LOGIN" && token) {
Object.assign(headers, bearerHeader(token));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers,
body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000),
});
if (res.ok) return "ok";
if (res.status === 401) return "auth_required";
return "unreachable";
} catch {
return "unreachable";
}
}
-256
View File
@@ -1,256 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "@core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
}
export async function importAppData(): Promise<void> {
const raw: number[] = await invoke("import_app_data");
const files = parseZip(new Uint8Array(raw));
const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
-134
View File
@@ -1,134 +0,0 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte";
import { getUIAccessToken } from "@core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
return blobUrl;
}
function insertSorted(entry: QueueEntry) {
let lo = 0, hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); });
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise);
scheduleDrain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve("");
const cached = cache.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) {
const idx = queue.findIndex(e => e.url === url);
if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
}
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
-4
View File
@@ -1,4 +0,0 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
-44
View File
@@ -1,44 +0,0 @@
interface MemEntry<T> {
value: T;
expiresAt: number;
key: string;
}
export class MemoryCache<T> {
readonly #cap: number;
readonly #ttl: number;
readonly #map = new Map<string, MemEntry<T>>();
constructor(capacity: number, ttlMs: number) {
this.#cap = capacity;
this.#ttl = ttlMs;
}
get(key: string): T | undefined {
const entry = this.#map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
this.#map.delete(key);
this.#map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.#map.has(key)) this.#map.delete(key);
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
}
has(key: string): boolean {
const entry = this.#map.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
return true;
}
delete(key: string): void { this.#map.delete(key); }
clear(): void { this.#map.clear(); }
get size(): number { return this.#map.size; }
}
-84
View File
@@ -1,84 +0,0 @@
import { gql, getServerUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
const cached = resolvedUrlCache.get(url);
if (cached) return cached;
const p = getBlobUrl(url, priority).catch(err => {
resolvedUrlCache.delete(url);
return Promise.reject(err);
});
resolvedUrlCache.set(url, p);
return p;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<string[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return resolveUrl(url, useBlob).then(src => new Promise(res => {
const img = new Image();
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
img.onerror = () => res(0.67);
img.src = src;
}));
}
export function preloadImage(url: string, useBlob: boolean): void {
if (useBlob) { preloadBlobUrls([url], 0); return; }
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
aspectCache.clear();
}
}
-241
View File
@@ -1,241 +0,0 @@
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number;
fetcher?: () => Promise<T>;
ttl?: number;
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
function registerGroups(key: string, group?: string | string[]) {
if (!group) return;
for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
keyToGroups.get(key)!.add(tag);
}
}
function unregisterKey(key: string) {
const tags = keyToGroups.get(key);
if (tags) {
for (const tag of tags) groups.get(tag)?.delete(key);
keyToGroups.delete(key);
}
}
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): 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 => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {});
return promise;
},
set<T>(key: string, value: T, group?: string | string[]) {
const existing = store.get(key) as Entry<T> | undefined;
store.set(key, {
promise: Promise.resolve(value),
fetchedAt: Date.now(),
fetcher: existing?.fetcher,
ttl: existing?.ttl,
});
registerGroups(key, group);
notify(key);
},
update<T>(key: string, fn: (prev: T) => T) {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
refresh<T>(key: string): Promise<T> | undefined {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing?.fetcher) return undefined;
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
return promise;
},
refreshGroup(tag: string): void {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
const existing = store.get(key);
if (existing?.fetcher) {
const promise = existing.fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
}
}
},
has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
isStale(key: string): boolean {
const e = store.get(key);
if (!e) return true;
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
},
clear(key: string) {
unregisterKey(key);
store.delete(key);
notify(key);
},
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
keyToGroups.get(key)?.delete(tag);
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
store.delete(key);
notify(key);
}
groups.delete(tag);
},
clearAll() {
const allKeys = [...store.keys()];
store.clear();
groups.clear();
keyToGroups.clear();
allKeys.forEach(notify);
},
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb);
return () => subs.get(key)?.delete(cb);
},
};
export const CACHE_GROUPS = {
LIBRARY: "g:library",
SOURCES: "g:sources",
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
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}`;
},
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;
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const p = fetcher().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
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); },
};
}
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
}
export function recordSourceAccess(sourceId: string) {
if (!sourceId || sourceId === "0") return;
const map = loadFrecency();
map[sourceId] = (map[sourceId] ?? 0) + 1;
saveFrecency(map);
}
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
if (withScore.some(x => x.score > 0)) {
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
}
return sources.slice(0, MAX_FRECENCY_SOURCES);
}
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {});
}
}
-27
View File
@@ -1,27 +0,0 @@
import { store, linkManga } from "@store/state.svelte";
import type { Manga } from "@types";
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise((resolve) => {
const worker = new Worker(
new URL("./autoLinkWorker.ts", import.meta.url),
{ type: "module" },
);
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data;
for (const id of matches) linkManga(focal.id, id);
worker.terminate();
resolve(matches.length);
};
worker.onerror = () => { worker.terminate(); resolve(0); };
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
});
});
}
-29
View File
@@ -1,29 +0,0 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wa = new Set(norm(a));
const wb = new Set(norm(b));
if (!wa.size || !wb.size) return 0;
const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
}
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [];
for (const m of allManga) {
if (m.id === focalId) continue;
if (linkedIds.includes(m.id)) continue;
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
}
self.postMessage(matches);
};
-54
View File
@@ -1,54 +0,0 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels);
for (let i = 0; i < pixels; i++) {
const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
}
return gray;
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
};
img.onerror = reject;
img.src = url;
});
}
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255);
}
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!;
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
} catch {
return null;
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
}
export function clearHashCache(): void {
hashCache.clear();
}
-95
View File
@@ -1,95 +0,0 @@
import { store } from "@store/state.svelte";
import { searchWithScore } from "@core/algorithms/search";
import { getHash, areDuplicates } from "@core/cover/coverHash";
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
export type CoverCandidate = {
mangaId: number;
url: string;
label: string;
isActive: boolean;
};
const FUZZY_SCORE_THRESHOLD = 0.65;
function normalizeUrl(url: string): string {
try {
const u = new URL(url);
u.search = "";
return u.href.toLowerCase();
} catch {
return url.toLowerCase();
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
const results = searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
);
return results
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id);
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = store.getLinkedMangaIds(mangaId);
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: "This source" },
...allIds.flatMap(id => {
const m = mangaById.get(id);
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
}),
];
const seen = new Set<string>();
return raw
.filter(c => {
const key = normalizeUrl(c.url);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
const groups: number[][] = [];
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i];
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
: undefined;
if (existing) existing.push(i);
else groups.push([i]);
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0];
const labels = [...new Set(group.map(i => candidates[i].label))];
return { ...candidates[active], label: labels.join(" · ") };
});
}
-4
View File
@@ -1,4 +0,0 @@
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
export type { CoverCandidate } from "./coverResolver";
export { autoLinkLibrary } from "./autoLink";
-50
View File
@@ -1,50 +0,0 @@
export interface Keybinds {
turnPageRight: string;
turnPageLeft: string;
firstPage: string;
lastPage: string;
turnChapterRight: string;
turnChapterLeft: string;
exitReader: string;
toggleReadingDirection: string;
togglePageStyle: string;
toggleFullscreen: string;
openSettings: string;
toggleBookmark: string;
toggleMarker: string;
toggleAutoScroll: string;
}
export const DEFAULT_KEYBINDS: Keybinds = {
turnPageRight: "ArrowRight",
turnPageLeft: "ArrowLeft",
firstPage: "ctrl+ArrowLeft",
lastPage: "ctrl+ArrowRight",
turnChapterRight: "]",
turnChapterLeft: "[",
exitReader: "Backspace",
toggleReadingDirection: "d",
togglePageStyle: "q",
toggleFullscreen: "f",
openSettings: "o",
toggleBookmark: "m",
toggleMarker: "n",
toggleAutoScroll: "s",
};
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
turnPageRight: "Turn page right (→)",
turnPageLeft: "Turn page left (←)",
firstPage: "Jump to first page",
lastPage: "Jump to last page",
turnChapterRight: "Turn chapter right (→)",
turnChapterLeft: "Turn chapter left (←)",
exitReader: "Exit reader",
toggleReadingDirection: "Toggle reading direction",
togglePageStyle: "Toggle page style",
toggleFullscreen: "Toggle fullscreen",
openSettings: "Open settings",
toggleBookmark: "Toggle bookmark",
toggleMarker: "Toggle marker",
toggleAutoScroll: "Toggle auto scroll",
};
-3
View File
@@ -1,3 +0,0 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
export type { Keybinds } from "./defaultBinds";
-25
View File
@@ -1,25 +0,0 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export function eventToKeybind(e: KeyboardEvent): string {
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("meta");
parts.push(e.key);
return parts.join("+");
}
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
try {
const win = getCurrentWindow();
await win.setFullscreen(!await win.isFullscreen());
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
-88
View File
@@ -1,88 +0,0 @@
const VAULT_KEY = "moku-credential-vault";
const SALT_ITERATIONS = 200_000;
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
export interface VaultPayload {
refreshToken?: string;
basicUser?: string;
basicPass?: string;
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
}
interface StoredVault {
salt: string;
iv: string;
data: string;
}
function toB64(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function fromB64(s: string): Uint8Array {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
}
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
keyMat,
{ name: "AES-GCM", length: 256 },
false,
KEY_USAGE,
);
}
export function vaultExists(): boolean {
return !!localStorage.getItem(VAULT_KEY);
}
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pin, salt);
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(payload)),
);
localStorage.setItem(VAULT_KEY, JSON.stringify({
salt: toB64(salt),
iv: toB64(iv),
data: toB64(cipher),
} satisfies StoredVault));
}
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
const raw = localStorage.getItem(VAULT_KEY);
if (!raw) return null;
try {
const stored = JSON.parse(raw) as StoredVault;
const key = await deriveKey(pin, fromB64(stored.salt));
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(stored.iv) },
key,
fromB64(stored.data),
);
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
} catch {
return null;
}
}
export function clearVault(): void {
localStorage.removeItem(VAULT_KEY);
}
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
const payload = await unlockVault(oldPin);
if (!payload) return false;
await lockVault(newPin, payload);
return true;
}
-5
View File
@@ -1,5 +0,0 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
-166
View File
@@ -1,166 +0,0 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
-67
View File
@@ -1,67 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
function applySystemTheme(dark: boolean) {
const themeId = dark
? (store.settings.systemThemeDark ?? "dark")
: (store.settings.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!store.settings.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}
-23
View File
@@ -1,23 +0,0 @@
import { store } from "@store/state.svelte";
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
if (timer) clearTimeout(timer);
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
timer = setTimeout(onIdle, ms);
onActive();
}
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
reset();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
};
}
-3
View File
@@ -1,3 +0,0 @@
export * from './idle';
export * from './zoom';
export * from './touchscreen';
-234
View File
@@ -1,234 +0,0 @@
export interface LongPressOptions {
onLongPress: (e: PointerEvent) => void;
duration?: number;
moveThreshold?: number;
}
export function longPress(node: HTMLElement, opts: LongPressOptions) {
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
let timer: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
let fired = false;
function start(e: PointerEvent) {
if (e.button !== 0 && e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; fired = false;
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
}
function move(e: PointerEvent) {
if (!timer) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
}
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
node.addEventListener("pointerdown", start);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", cancel);
node.addEventListener("pointerleave", cancel);
node.addEventListener("pointercancel",cancel);
return {
get fired() { return fired; },
destroy() {
cancel();
node.removeEventListener("pointerdown", start);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", cancel);
node.removeEventListener("pointerleave", cancel);
node.removeEventListener("pointercancel",cancel);
},
};
}
export interface TapOptions {
onTap: (e: PointerEvent) => void;
onDoubleTap?: (e: PointerEvent) => void;
doubleTapGap?: number;
}
export function tap(node: HTMLElement, opts: TapOptions) {
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
let lastTap = 0;
let pending: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
const SLOP = 8;
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
function up(e: PointerEvent) {
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
const now = Date.now();
if (onDoubleTap && now - lastTap < doubleTapGap) {
if (pending) { clearTimeout(pending); pending = null; }
onDoubleTap(e);
lastTap = 0;
} else {
lastTap = now;
if (onDoubleTap) {
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
} else {
onTap(e);
}
}
}
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
}};
}
export interface SwipeOptions {
onSwipeLeft?: (e: PointerEvent) => void;
onSwipeRight?: (e: PointerEvent) => void;
onSwipeUp?: (e: PointerEvent) => void;
onSwipeDown?: (e: PointerEvent) => void;
threshold?: number;
lockAxis?: boolean;
}
export function swipe(node: HTMLElement, opts: SwipeOptions) {
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
let startX = 0, startY = 0, active = false;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; active = true;
node.setPointerCapture(e.pointerId);
}
function up(e: PointerEvent) {
if (!active) return; active = false;
const dx = e.clientX - startX, dy = e.clientY - startY;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < threshold) return;
if (lockAxis && ax > ay) {
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
} else if (lockAxis && ay >= ax) {
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
} else {
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
}
}
function cancel() { active = false; }
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", cancel);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", cancel);
}};
}
export interface PinchOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGestureOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export interface PinchGesture {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
isPinching: () => boolean;
}
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
const { onPinch, onPinchEnd } = opts;
const pointers = new Map<number, PointerEvent>();
let initDist = 0;
function pdist(a: PointerEvent, b: PointerEvent) {
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function pmid(a: PointerEvent, b: PointerEvent) {
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
}
function onPointerDown(e: PointerEvent) {
pointers.set(e.pointerId, e);
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
initDist = pdist(a, b);
}
}
function onPointerMove(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, e);
if (pointers.size !== 2 || initDist === 0) return;
const [a, b] = [...pointers.values()];
onPinch(pdist(a, b) / initDist, pmid(a, b));
}
function onPointerUp(e: PointerEvent) {
if (pointers.size === 2 && onPinchEnd) {
const [a, b] = [...pointers.values()];
onPinchEnd(pdist(a, b) / initDist);
}
pointers.delete(e.pointerId);
initDist = 0;
}
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
}
export function pinch(node: HTMLElement, opts: PinchOptions) {
const gesture = createPinchGesture(opts);
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", gesture.onPointerMove);
node.addEventListener("pointerup", gesture.onPointerUp);
node.addEventListener("pointercancel", gesture.onPointerUp);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", gesture.onPointerMove);
node.removeEventListener("pointerup", gesture.onPointerUp);
node.removeEventListener("pointercancel", gesture.onPointerUp);
}};
}
export interface DragScrollOptions {
direction?: "x" | "y" | "both";
onDragStart?: () => void;
onDragEnd?: () => void;
}
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
const { direction = "both", onDragStart, onDragEnd } = opts;
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
active = true;
startX = e.clientX; startY = e.clientY;
scrollX = node.scrollLeft; scrollY = node.scrollTop;
node.setPointerCapture(e.pointerId);
onDragStart?.();
}
function move(e: PointerEvent) {
if (!active) return;
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
}
function up() { if (active) { active = false; onDragEnd?.(); } }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", up);
}};
}
-61
View File
@@ -1,61 +0,0 @@
import { store } from "@store/state.svelte";
let _appliedZoom: number = -1;
let _vhRafId: number | null = null;
export function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${uiZoom * 100}%`;
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
});
}
export function handleZoomKey(e: KeyboardEvent) {
if (!e.ctrlKey) return;
const current = store.settings.uiZoom ?? 1.0;
if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; }
}
export function mountZoomKey(): () => void {
window.addEventListener("keydown", handleZoomKey);
return () => window.removeEventListener("keydown", handleZoomKey);
}
export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
export function captureZoomAnchor(
containerEl: HTMLElement | null,
style: string,
out: { el: HTMLElement | null; offset: number },
) {
if (!containerEl || style !== "longstrip") return;
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
}
}
export function restoreZoomAnchor(
containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number },
) {
if (!out.el || !containerEl) return;
const el = out.el;
out.el = null;
requestAnimationFrame(() => {
const containerTop = containerEl!.getBoundingClientRect().top;
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
});
}
-40
View File
@@ -1,40 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { addToast } from "@store/state.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
-223
View File
@@ -1,223 +0,0 @@
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
export { clsx as cn } from "clsx";
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
export function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "explicit", "sexual violence",
"gore", "guro", "graphic violence", "torture", "body horror",
];
const MODERATE_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "explicit", "sexual violence",
];
type ContentFilterSettings = Pick<
Settings,
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
>;
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
if (settings.contentLevel === "strict") return STRICT_TAGS;
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
return [];
}
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false;
return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
}
export function shouldHideNsfw(
manga: Pick<Manga, "genre" | "source">,
settings: ContentFilterSettings,
): boolean {
if (settings.contentLevel === "unrestricted") return false;
const srcId = manga.source?.id;
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId));
if (!sourceAllowed && manga.source?.isNsfw) return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
}
export function shouldHideSource(
source: Pick<Source, "id" | "isNsfw">,
settings: ContentFilterSettings,
): boolean {
if (settings.contentLevel === "unrestricted") return false;
if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
}
return source.isNsfw;
}
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: ContentFilterSettings,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>();
for (const src of sources) {
if (src.id === "0") continue;
if (!byName.has(src.name)) byName.set(src.name, []);
byName.get(src.name)!.push(src);
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find(s => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
return picked;
}
export function normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "")
.replace(/[^a-z0-9\s]/g, " ")
.replace(/^(the|a|an)\s+/, "")
.replace(/\s+/g, " ")
.trim();
}
function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
}
function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null;
const n = norm(desc);
return n.length >= 60 ? n.slice(0, 200) : null;
}
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
return parts.length ? parts.sort().join("|") : null;
}
export function dedupeMangaByTitle<T extends {
id: number;
title: string;
description?: string | null;
author?: string | null;
artist?: string | null;
inLibrary?: boolean;
downloadCount?: number;
}>(items: T[], links: Record<number, number[]> = {}): T[] {
const byTitle = new Map<string, number>();
const byDesc = new Map<string, number>();
const byAuthorDesc = new Map<string, number>();
const byId = new Map<number, number>();
const out: T[] = [];
for (const m of items) {
const tk = normalizeTitle(m.title);
const dk = descFingerprint(m.description);
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
const linkedIds = links[m.id] ?? [];
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
const existingIdx =
linkedIdx ??
byTitle.get(tk) ??
(dk ? byDesc.get(dk) : undefined) ??
(ak ? byAuthorDesc.get(ak) : undefined);
if (existingIdx !== undefined) {
const existing = out[existingIdx];
const mBetter =
(m.inLibrary && !existing.inLibrary) ||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
if (mBetter) {
out[existingIdx] = m;
byTitle.set(tk, existingIdx);
byId.set(m.id, existingIdx);
if (dk) byDesc.set(dk, existingIdx);
if (ak) byAuthorDesc.set(ak, existingIdx);
}
continue;
}
const idx = out.length;
out.push(m);
byTitle.set(tk, idx);
byId.set(m.id, idx);
if (dk) byDesc.set(dk, idx);
if (ak) byAuthorDesc.set(ak, idx);
}
return out;
}
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>();
const out: T[] = [];
for (const m of items) {
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
}
return out;
}
-4
View File
@@ -1,4 +0,0 @@
@import "./reset.css";
@import "./animations.css";
@import "./scrollbars.css";
@import "./typography.css";
-41
View File
@@ -1,41 +0,0 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#app {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
-9
View File
@@ -1,9 +0,0 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
-9
View File
@@ -1,9 +0,0 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="dark"] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
-5
View File
@@ -1,5 +0,0 @@
@import "./original.css";
@import "./dark.css";
@import "./light.css";
@import "./midnight.css";
@import "./warm.css";
-29
View File
@@ -1,29 +0,0 @@
[data-theme="light"] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
-31
View File
@@ -1,31 +0,0 @@
[data-theme="original"] {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
-35
View File
@@ -1,35 +0,0 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
}
-8
View File
@@ -1,8 +0,0 @@
@import "./colors.css";
@import "./typography.css";
@import "./spacing.css";
@import "./radius.css";
@import "./motion.css";
@import "./shadows.css";
@import "./zindex.css";
@import "../themes/index.css";
-5
View File
@@ -1,5 +0,0 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
-8
View File
@@ -1,8 +0,0 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
-2
View File
@@ -1,2 +0,0 @@
:root {
}
-13
View File
@@ -1,13 +0,0 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
}
-28
View File
@@ -1,28 +0,0 @@
:root {
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
-5
View File
@@ -1,5 +0,0 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
View File
View File
@@ -1,275 +0,0 @@
<script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
import type { Manga, Source, Category } from "@types/index";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import {
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
parseTags, tagsLabel, matchesAllTags, runConcurrent,
} from "@features/discover/lib/searchFilter";
const prevNavPage = store.navPage;
const tags = $derived(parseTags(store.genreFilter));
const primaryTag = $derived(tags[0] ?? "");
const label = $derived(tagsLabel(tags));
let libraryManga: Manga[] = $state([]);
let sourceManga: Manga[] = $state([]);
let loadingInitial = $state(true);
let loadingMore = $state(false);
let visibleCount = $state(PAGE_SIZE);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const nextPageMap = new Map<string, number>();
let sources: Source[] = $state([]);
let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
});
const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length);
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
async function load(filter: string) {
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
loadingInitial = true;
sourceManga = [];
libraryManga = [];
visibleCount = PAGE_SIZE;
nextPageMap.clear();
const preferredLang = store.settings.preferredExtensionLang || "en";
const t = parseTags(filter);
const pt = t[0] ?? "";
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
}),
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
cache.get(
CACHE_KEYS.SOURCES,
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => {
const srcs = allSources.slice(0, MAX_SOURCES);
sources = srcs;
for (const src of srcs) nextPageMap.set(src.id, -1);
await runConcurrent(srcs, async (src) => {
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", t);
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
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: pt }, ctrl.signal,
).then((d) => d.fetchSourceManga),
).catch(() => null);
if (!result || ctrl.signal.aborted) break;
ps.add(page);
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
loadingInitial = false;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) loadingInitial = false;
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
}
async function loadMore() {
if (loadingMore) return;
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
if (!srcs.length) return;
loadingMore = true;
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
try {
await runConcurrent(srcs, async (src) => {
const page = nextPageMap.get(src.id)!;
if (ctrl.signal.aborted) return;
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(() => { nextPageMap.set(src.id, -1); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageMap.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) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
}
}
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then((d) => { categories = d.categories.nodes.filter((c) => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(
CREATE_CATEGORY, { name: name.trim() },
).catch(console.error);
if (res) {
const cat = res.createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
},
},
];
}
$effect(() => () => { abortCtrl?.abort(); });
</script>
<div class="root">
<div class="header">
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
<ArrowLeft size={13} weight="light" /><span>Back</span>
</button>
<span class="title">{label}</span>
{#if !loadingInitial || filtered.length > 0}
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
{/if}
{#if !loadingInitial && hasMoreNetwork}
<span class="loading-hint">More loading…</span>
{/if}
</div>
{#if loadingInitial && filtered.length === 0}
<div class="grid">
{#each Array(50) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">No manga found for "{label}".</div>
{:else}
<div class="grid">
{#each visibleItems as m, i (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div>
<p class="card-title">{m.title}</p>
</button>
{/each}
{#if hasMore}
<div class="show-more-cell">
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
</button>
</div>
{/if}
</div>
{/if}
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .card-title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -1,337 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { runConcurrent } from "@core/async/batchRequests";
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
import { store } from "@store/state.svelte";
import { preloadBlobUrls } from "@core/cache/imageCache";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "@types";
import type { CachedManga } from "@features/discover/lib/searchFilter";
interface Props {
allSources: Source[];
availableLangs: string[];
hasMultipleLangs: boolean;
loadingSources: boolean;
pendingPrefill: string;
popularResults: (Manga & { _priority: number })[];
popularLoading: boolean;
sourceCache: Map<number, CachedManga>;
onPrefillConsumed: () => void;
onPreview: (m: Manga) => void;
}
let {
allSources, availableLangs, hasMultipleLangs, loadingSources,
pendingPrefill, popularResults, popularLoading,
sourceCache,
onPrefillConsumed, onPreview,
}: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let kw_query = $state("");
let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set());
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
}
$effect(() => {
if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
});
$effect(() => {
if (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill;
onPrefillConsumed();
kw_query = q;
kwDoSearch(q);
}
});
$effect(() => {
const q = kw_query;
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
});
function kwGetVisibleSources(): Source[] {
let filtered = allSources;
if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (store.settings.contentLevel !== "unrestricted")
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered;
}
async function kwDoSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) return;
const visible = kwGetVisibleSources();
if (!visible.length) return;
kw_abortCtrl?.abort();
const ctrl = new AbortController();
kw_abortCtrl = ctrl;
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
kw_results = initial;
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
const idx = indexBySrcId.get(src.id)!;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
preloadBlobUrls(
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
12,
);
const next = [...kw_results];
next[idx] = { ...next[idx], mangas, loading: false };
kw_results = next;
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
const next = [...kw_results];
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
kw_results = next;
}
}, ctrl.signal);
}
function kwToggleLang(lang: string) {
const next = new Set(kw_selectedLangs);
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
else next.add(lang);
kw_selectedLangs = next;
}
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
const kw_flatResults = $derived.by(() => {
const all = kw_results.flatMap((r) =>
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
);
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
});
onDestroy(() => {
kw_abortCtrl?.abort();
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
});
</script>
<div class="keywordBar">
<div class="searchBar">
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:this={kw_inputEl}
bind:value={kw_query}
class="searchInput"
placeholder="Search across sources…"
use:focusOnMount
/>
{#if kw_anyLoading}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if kw_query}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
{/if}
{#if hasMultipleLangs}
<button
class="advancedBtn"
class:advancedBtnActive={kw_showAdvanced}
title="Language & filter options"
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
>
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
</svg>
</button>
{/if}
</div>
{#if hasMultipleLangs && kw_showAdvanced}
<div class="advancedPanel">
<div class="advancedHeader">
<span class="advancedTitle">Languages</span>
<div class="advancedActions">
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
</div>
</div>
<div class="langGrid">
{#each availableLangs as lang (lang)}
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
{lang === preferredLang ? `${lang.toUpperCase()} ` : lang.toUpperCase()}
</button>
{/each}
</div>
<div class="advancedDivider"></div>
<div class="advancedFooter">
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
</div>
</div>
{/if}
</div>
{#if !kw_query.trim()}
{#if popularLoading && popularResults.length === 0}
<div class="searchGrid">
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if popularResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">Popular right now</span>
</div>
<div class="searchGrid">
{#each popularResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if popularLoading}
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else}
<div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<p class="emptyText">Search across sources</p>
<p class="emptyHint">
{#if hasMultipleLangs}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
{:else}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
{/if}
</p>
</div>
{/if}
{:else}
{#if kw_flatResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
</div>
<div class="searchGrid">
{#each kw_flatResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if kw_anyLoading}
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else if kw_anyLoading}
<div class="searchGrid">
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if kw_allDone && !kw_hasResults}
<div class="empty">
<p class="emptyText">No results for "{kw_query.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p>
</div>
{/if}
{/if}
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<style>
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.advancedActions { display: flex; gap: var(--sp-2); }
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.advancedLink:hover { opacity: 0.75; }
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedDivider { height: 1px; background: var(--border-dim); }
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
@@ -1,327 +0,0 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { GET_SOURCES } from "@api/queries/extensions";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_MANGA } from "@api/mutations/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
import {
toCachedManga,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@types";
import KeywordTab from "./KeywordTab.svelte";
import TagTab from "./TagTab.svelte";
import SourceTab from "./SourceTab.svelte";
const anims = $derived(store.settings.qolAnimations ?? true);
const TABS = ["keyword", "tag", "source"] as const;
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200;
const SEARCH_BATCH = 20;
const POPULAR_CACHE_PAGES = 3;
type SearchTab = "keyword" | "tag" | "source";
let tab: SearchTab = $state("keyword");
let pendingPrefill = $state("");
$effect(() => {
if (store.searchPrefill) {
const prefill = store.searchPrefill;
untrack(() => {
pendingPrefill = prefill;
tab = "keyword";
setSearchPrefill("");
});
}
});
let allSources: Source[] = $state([]);
let localSource: Source | null = $state(null);
let loadingSources = $state(false);
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
loadingSources = true;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => {
const nodes = d.sources.nodes;
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
allSources = nodes.filter((src: Source) => src.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { loadingSources = false; });
let popular_raw: Manga[] = $state([]);
let popular_loading = $state(false);
let popular_moreLoading = $state(false);
let popular_abortCtrl: AbortController | null = null;
let popular_sourcePool: Source[] = $state([]);
let popular_sourceCursor = $state(0);
let popular_hasMore = $state(false);
let popular_seenIds = new Set<number>();
let popular_seenTitles = new Set<string>();
const popular_results: (Manga & { _priority: number })[] = $derived(
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
);
function popular_push(incoming: Manga[]) {
const toAdd: Manga[] = [];
for (const m of incoming) {
if (shouldHideNsfw(m, store.settings)) continue;
if (popular_seenIds.has(m.id)) continue;
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
if (popular_seenTitles.has(norm)) continue;
popular_seenIds.add(m.id);
popular_seenTitles.add(norm);
toAdd.push(m);
}
if (!toAdd.length) return;
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
}
async function popular_fanOut(signal: AbortSignal) {
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
if (!batch.length) { popular_hasMore = false; return; }
await runConcurrent(batch, async (src) => {
for (let page = 1; page <= SEARCH_PAGES; page++) {
if (signal.aborted) return;
const key = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(key)) {
mangas = store.searchCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page, query: null },
signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || signal.aborted) break;
mangas = result.mangas;
store.searchCache?.set(key, mangas);
if (!result.hasNextPage) { popular_push(mangas); break; }
}
popular_push(mangas);
}
}, signal);
popular_sourceCursor += batch.length;
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
}
function popularStart(sources: Source[]) {
if (popular_raw.length > 0) return;
popular_abortCtrl?.abort();
const ctrl = new AbortController();
popular_abortCtrl = ctrl;
popular_seenIds.clear();
popular_seenTitles.clear();
popular_raw = [];
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
popular_sourceCursor = 0;
popular_hasMore = false;
popular_moreLoading = false;
popular_loading = true;
(async () => {
try {
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
await popular_fanOut(ctrl.signal);
}
} catch {}
if (!ctrl.signal.aborted) popular_loading = false;
})();
}
export const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false);
let sourceCacheLoading = $state(false);
let sourceCacheEnriching = $state(false);
let sourceCacheAbort: AbortController | null = null;
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
const tasks: { src: Source; page: number }[] = [];
for (const src of sources) {
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
}
await runConcurrent(tasks, async ({ src, page }) => {
if (signal.aborted) return;
try {
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page },
signal,
);
if (signal.aborted) return;
mangas = d.fetchSourceManga.mangas;
store.searchCache?.set(cacheKey, mangas);
}
for (const m of mangas) {
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
}
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}, signal);
}
async function enrichGenres(signal: AbortSignal) {
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
if (!unenriched.length) return;
sourceCacheEnriching = true;
await runConcurrent(unenriched, async (entry) => {
if (signal.aborted) return;
try {
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
FETCH_MANGA, { id: entry.id }, signal,
);
if (signal.aborted) return;
const updated = sourceCache.get(entry.id);
if (updated) {
updated.genre = d.fetchManga.manga.genre ?? [];
updated.status = d.fetchManga.manga.status ?? updated.status;
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
updated.genreEnriched = true;
}
} catch (e: any) {
if (e?.name === "AbortError") return;
const updated = sourceCache.get(entry.id);
if (updated) updated.genreEnriched = true;
}
}, signal);
if (!signal.aborted) sourceCacheEnriching = false;
}
function startSourceCacheBuild() {
if (sourceCacheLoading || sourceCacheReady) return;
sourceCacheAbort?.abort();
const ctrl = new AbortController();
sourceCacheAbort = ctrl;
sourceCacheLoading = true;
sourceCache.clear();
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
buildSourceCache(dedupedSources, ctrl.signal)
.then(() => {
if (ctrl.signal.aborted) return;
sourceCacheReady = true;
sourceCacheLoading = false;
enrichGenres(ctrl.signal);
})
.catch((e) => {
if (e?.name !== "AbortError") console.error(e);
sourceCacheLoading = false;
});
}
onDestroy(() => {
popular_abortCtrl?.abort();
sourceCacheAbort?.abort();
});
</script>
<div class="root anim-fade-in">
<div class="header">
<span class="heading">Search</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
Keyword
</button>
<button class="tab" class:tabActive={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "tag"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
Tags
</button>
<button class="tab" class:tabActive={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "source"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
Sources
</button>
</div>
</div>
{#if tab === "keyword"}
<KeywordTab
{allSources}
{availableLangs}
{hasMultipleLangs}
{loadingSources}
{pendingPrefill}
popularResults={popular_results}
popularLoading={popular_loading}
{sourceCache}
query={store.searchQuery}
onQueryChange={setSearchQuery}
onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga}
/>
{:else if tab === "tag"}
<TagTab
{allSources}
{sourceCache}
{sourceCacheReady}
{sourceCacheLoading}
{sourceCacheEnriching}
onPreview={setPreviewManga}
/>
{:else}
<SourceTab
{allSources}
{availableLangs}
{loadingSources}
{localSource}
onPreview={setPreviewManga}
/>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
.tab:hover { color: var(--text-muted); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
.tabActive:hover { color: var(--accent-fg); }
</style>
@@ -1,369 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { shouldHideNsfw, shouldHideSource } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
availableLangs: string[];
loadingSources: boolean;
localSource: Source | null;
onPreview: (m: Manga) => void;
}
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let src_selectedLang = $state(preferredLang || "all");
let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false);
let src_browseQuery = $state("");
let src_submitted = $state("");
let src_hasNextPage = $state(false);
let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null;
let ctx_x = $state(0);
let ctx_y = $state(0);
let ctx_source: Source | null = $state(null);
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
const pinnedSources = $derived(
pinnedIds
.map(id => allSources.find(s => s.id === id))
.filter((s): s is Source => !!s)
);
$effect(() => {
if (!allSources.length) return;
const langs = new Set(allSources.map((s) => s.lang));
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
}
});
const src_visibleSources = $derived.by(() => {
const hide = (s: Source) => shouldHideSource(s, store.settings);
if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
}
const map = new Map<string, Source>();
for (const s of allSources) {
if (hide(s)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort();
const ctrl = new AbortController();
src_abortCtrl = ctrl;
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query: q ?? null },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page;
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) src_loadingBrowse = false;
}
}
function srcSelectSource(src: Source) {
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
srcFetchBrowse(src, "POPULAR");
}
function srcHandleSearch() {
if (!src_activeSource || !src_browseQuery.trim()) return;
src_submitted = src_browseQuery.trim();
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
}
function srcClearSearch() {
src_browseQuery = ""; src_submitted = "";
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
}
function openCtx(e: MouseEvent, src: Source) {
e.preventDefault();
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
}
function closeCtx() { ctx_source = null; }
onDestroy(() => { src_abortCtrl?.abort(); });
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="srcLangRow">
<span class="langPocketLabel">Language</span>
<select class="langSelect" bind:value={src_selectedLang}>
<option value="all">All</option>
{#each availableLangs as lang (lang)}
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
{/each}
</select>
</div>
{#if loadingSources}
<div class="splitLoading">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
</div>
{:else}
<div class="splitList">
{#if localSource}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === localSource.id}
onclick={() => srcSelectSource(localSource)}
oncontextmenu={(e) => openCtx(e, localSource)}
>
<div class="localSourceIcon">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
</svg>
</div>
<span class="splitItemLabel">Local Source</span>
</button>
<div class="localDivider"></div>
{/if}
{#if pinnedSources.length > 0}
<p class="sectionLabel">Pinned</p>
{#each pinnedSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
<span class="pinIndicator" title="Pinned">
<PushPin size={9} weight="fill" />
</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
<div class="localDivider"></div>
<p class="sectionLabel">All Sources</p>
{/if}
{#each src_visibleSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"}
<span class="sourceLang">{src.lang.toUpperCase()}</span>
{/if}
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
{#if src_visibleSources.length === 0}
<p class="splitEmpty">No sources for this language</p>
{/if}
</div>
{/if}
</div>
<div class="splitContent">
{#if !src_activeSource}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
<p class="emptyText">Browse a source</p>
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
</div>
{:else}
<div class="splitContentHeader">
<div class="splitSourceTitle">
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if src_browseResults.length > 0}
<span class="splitResultCount">{src_browseResults.length} results</span>
{/if}
</div>
</div>
<div class="sourceBrowseBar">
<div class="searchBar" style="flex:1">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:value={src_browseQuery}
class="searchInput"
placeholder="Search {src_activeSource.displayName}…"
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
/>
{#if src_submitted}
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
{/if}
</div>
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
</div>
{#if src_loadingBrowse && src_browseResults.length === 0}
<div class="tagGrid">
{#each Array(18) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#each src_browseResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if src_hasNextPage}
<div class="showMoreCell">
<button
class="showMoreBtn"
disabled={src_loadingBrowse}
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
>
{src_loadingBrowse ? "Loading…" : "Load more"}
</button>
</div>
{/if}
</div>
{:else if !src_loadingBrowse}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">Try a different search term.</p>
</div>
{/if}
{/if}
</div>
</div>
{#if ctx_source}
{@const isPinned = pinnedIds.includes(ctx_source.id)}
<ContextMenu
x={ctx_x}
y={ctx_y}
onClose={closeCtx}
items={[
{
label: isPinned ? "Unpin source" : "Pin source",
icon: isPinned ? PushPinSlash : PushPin,
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
},
{ separator: true },
{
label: "Browse source",
icon: ArrowRight,
onClick: () => { srcSelectSource(ctx_source!); },
},
]}
/>
{/if}
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemSource { gap: var(--sp-2); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.searchBtn:disabled { opacity: 0.4; cursor: default; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
@@ -1,474 +0,0 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { MANGAS_BY_GENRE } from "@api/queries/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import {
buildTagFilter,
filterSourceCache,
COMMON_GENRES,
MANGA_STATUSES,
type TagMode,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
sourceCache: Map<number, CachedManga>;
sourceCacheReady: boolean;
sourceCacheLoading: boolean;
sourceCacheEnriching: boolean;
onPreview: (m: Manga) => void;
}
let {
allSources, sourceCache,
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
onPreview,
}: Props = $props();
const SEARCH_LIMIT = 200;
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let tag_activeTags: string[] = $state([]);
let tag_activeStatuses: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state("");
const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
});
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false);
let tag_loadingMoreLocal = $state(false);
let tag_localOffset = $state(0);
let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null;
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
});
$effect(() => {
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0 && activeStatuses.length === 0) {
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
return;
}
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
tag_loadingLocal = true;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = (store.settings.renderLimit ?? 48);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
if (!ctrl.signal.aborted) tag_loadingLocal = false;
});
}
async function tagLoadMoreLocal() {
if (tag_loadingMoreLocal || !tag_localHasNext) return;
tag_loadingMoreLocal = true;
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
}
}
let tag_searchSources = $state(false);
let tag_sourceFiltered: CachedManga[] = $state([]);
let tag_sourceFanOut: Manga[] = $state([]);
let tag_fanOutLoading = $state(false);
let tag_fanOutAbort: AbortController | null = null;
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
const _ready = sourceCacheReady;
const _search = tag_searchSources;
untrack(() => {
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
} else {
tag_sourceFiltered = [];
}
});
});
$effect(() => {
const _tags = tag_activeTags;
const _search = tag_searchSources;
untrack(() => {
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
tagStartFanOut(_tags[0]);
} else {
tag_fanOutAbort?.abort();
tag_fanOutAbort = null;
tag_sourceFanOut = [];
tag_fanOutLoading = false;
}
});
});
async function tagStartFanOut(genre: string) {
tag_fanOutAbort?.abort();
const ctrl = new AbortController();
tag_fanOutAbort = ctrl;
tag_sourceFanOut = [];
tag_fanOutLoading = true;
const seenIds = new Set<number>();
const seenTitles = new Set<string>();
const genreLower = genre.toLowerCase();
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
await runConcurrent(srcs, async (src) => {
for (let page = 1; page <= 2; page++) {
if (ctrl.signal.aborted) return;
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
let mangas: Manga[];
let hasNextPage = false;
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
hasNextPage = result.hasNextPage;
store.searchCache?.set(cacheKey, mangas);
}
if (ctrl.signal.aborted) return;
const matching = mangas.filter((m) =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
);
const candidates = (matching.length ? matching : mangas).filter(
(m) => !shouldHideNsfw(m, store.settings)
);
const toAdd: Manga[] = [];
for (const m of candidates) {
if (seenIds.has(m.id)) continue;
const norm = normalizeTitle(m.title);
if (seenTitles.has(norm)) continue;
seenIds.add(m.id);
seenTitles.add(norm);
toAdd.push(m);
}
if (toAdd.length) {
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
}
if (!hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
}
let tag_autoSearchFired = $state(false);
$effect(() => {
const _tags = tag_activeTags;
const _statuses = tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
}
});
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived.by(() => {
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
const cacheMapped: Manga[] = tag_sourceFiltered
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
return dedupeMangaByTitle(
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
store.settings.mangaLinks,
);
});
const tag_totalVisible = $derived(tag_mergedResults.length);
function tagToggleTag(tag: string) {
tag_activeTags = tag_activeTags.includes(tag)
? tag_activeTags.filter((t) => t !== tag)
: [...tag_activeTags, tag];
}
function tagToggleStatus(status: string) {
tag_activeStatuses = tag_activeStatuses.includes(status)
? tag_activeStatuses.filter((s) => s !== status)
: [...tag_activeStatuses, status];
}
onDestroy(() => {
tag_abortLocal?.abort();
tag_fanOutAbort?.abort();
});
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="splitSearchWrap">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
{#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if}
</div>
<div class="splitList">
<div class="splitSectionLabel">Status</div>
{#each MANGA_STATUSES as { value, label } (value)}
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
<span class="splitItemLabel">{label}</span>
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
{#each tag_filteredGenres as tag (tag)}
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
<span class="splitItemLabel">{tag}</span>
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
{#if tag_filteredGenres.length === 0}
<p class="splitEmpty">No matching genres</p>
{/if}
</div>
</div>
<div class="splitContent">
{#if !tag_hasActiveFilters}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
<p class="emptyText">Browse by tag</p>
<p class="emptyHint">Select a status or genre to find matching manga.</p>
</div>
{:else}
<div class="tagActiveBar">
<div class="tagPillRow">
{#each tag_activeStatuses as status (status)}
<span class="tagPill tagPillStatus">
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
</span>
{/each}
{#each tag_activeTags as tag (tag)}
<span class="tagPill">
{tag}
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
</span>
{/each}
</div>
<div class="tagBarRight">
{#if tag_activeTags.length > 1}
<div class="tagModeToggle">
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
</div>
{/if}
<button
class="tagModeBtn"
class:tagModeBtnActive={tag_searchSources}
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
disabled={!sourceCacheReady && !sourceCacheLoading}
onclick={() => (tag_searchSources = !tag_searchSources)}
>
{#if sourceCacheLoading || tag_fanOutLoading}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
</svg>
{/if}
Sources{sourceCacheEnriching ? " ·" : ""}
</button>
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
</div>
</div>
<div class="splitContentHeader">
<span class="splitContentTitle">
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
{tag_activeTags[0]}
{:else}
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
{/if}
{#if tag_searchSources}
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
{/if}
</span>
{#if tag_loadingLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else}
<span class="splitResultCount">
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
{#if tag_searchSources && sourceCacheReady}
· {sourceCache.size} cached
{/if}
</span>
{/if}
</div>
{#if tag_loadingLocal}
<div class="tagGrid">
{#each Array(48) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#each tag_mergedResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if tag_loadingMoreLocal}
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
{/if}
</div>
{:else}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">
{#if tag_searchSources}Try OR mode or broader tags.
{:else}Try OR mode, enable Sources, or check your library.
{/if}
</p>
</div>
{/if}
{/if}
</div>
</div>
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
.splitSearchInput::placeholder { color: var(--text-faint); }
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.splitSearchClear:hover { color: var(--text-muted); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.tagPillRemove:hover { opacity: 1; }
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.tagModeBtn:last-child { border-right: none; }
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
-2
View File
@@ -1,2 +0,0 @@
export { default as Search } from "./components/Search.svelte";
export * from "./lib/searchFilter";
-134
View File
@@ -1,134 +0,0 @@
import type { Settings } from "@types";
import { shouldHideNsfw } from "@core/util";
export const PAGE_SIZE = 50;
export const INITIAL_PAGES = 3;
export const MAX_SOURCES = 12;
export const CONCURRENCY = 4;
export function parseTags(f: string): string[] {
return f.split("+").map((t) => t.trim()).filter(Boolean);
}
export function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
export function matchesAllTags(m: { genre?: string[] }, tags: string[]): boolean {
const g = (m.genre ?? []).map((x) => x.toLowerCase());
return tags.every((t) => g.includes(t.toLowerCase()));
}
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
await fn(items[i++]).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
export type TagMode = "AND" | "OR";
export interface CachedManga {
id: number;
title: string;
thumbnailUrl: string;
inLibrary: boolean;
status: string;
genre: string[];
lowerGenres: string[];
sourceId: string;
genreEnriched: boolean;
}
export const COMMON_GENRES = [
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
"Supernatural", "Mecha", "Historical", "Psychological", "School Life",
"Shounen", "Seinen", "Josei", "Shoujo", "Isekai", "Martial Arts",
"Magic", "Music", "Cooking", "Medical", "Military", "Harem", "Ecchi",
] as const;
export const MANGA_STATUSES: { value: string; label: string }[] = [
{ value: "ONGOING", label: "Ongoing" },
{ value: "COMPLETED", label: "Completed" },
{ value: "HIATUS", label: "Hiatus" },
{ value: "ABANDONED", label: "Abandoned" },
{ value: "UNKNOWN", label: "Unknown" },
];
export function buildTagFilter(
tags: string[],
mode: TagMode,
statuses: string[],
): Record<string, unknown> {
const genrePart: Record<string, unknown> | null =
tags.length === 0 ? null :
mode === "AND"
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
const statusPart: Record<string, unknown> | null =
statuses.length === 0 ? null :
statuses.length === 1
? { status: { equalTo: statuses[0] } }
: { or: statuses.map((s) => ({ status: { equalTo: s } })) };
if (!genrePart && !statusPart) return {};
if (genrePart && !statusPart) return genrePart;
if (!genrePart && statusPart) return statusPart;
return { and: [genrePart, statusPart] };
}
export function filterSourceCache(
sourceCache: Map<number, CachedManga>,
tags: string[],
mode: TagMode,
statuses: string[],
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
): CachedManga[] {
return [...sourceCache.values()].filter((m) => {
if (shouldHideNsfw(m as any, settings)) return false;
const statusMatch =
statuses.length === 0 || statuses.includes(m.status);
let genreMatch = true;
if (tags.length > 0) {
const lower = m.lowerGenres;
if (mode === "AND") {
genreMatch = tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())));
} else {
genreMatch = tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
}
}
return statusMatch && genreMatch;
});
}
export function toCachedManga(
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
srcId: string,
): CachedManga {
const genre = m.genre ?? [];
return {
id: m.id,
title: m.title,
thumbnailUrl: m.thumbnailUrl,
inLibrary: m.inLibrary,
status: m.status ?? "UNKNOWN",
genre,
lowerGenres: genre.map((g) => g.toLowerCase()),
sourceId: srcId,
genreEnriched: genre.length > 0,
};
}
@@ -1,140 +0,0 @@
<script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
isSelected: boolean;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
item, isActive, isRemoving, isSelected,
onRemove, onRetry, onSelect,
}: Props = $props();
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100));
function rowLongPress(node: HTMLElement) {
return longPress(node, {
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
});
}
</script>
<div
class="row"
class:row-active={isActive}
class:row-error={isError}
class:row-selected={isSelected}
class:row-removing={isRemoving}
role="option"
aria-selected={isSelected}
tabindex="0"
use:rowLongPress
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
>
{#if manga?.thumbnailUrl}
<div class="thumb">
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="thumb-img" />
</div>
{/if}
<div class="info">
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
<span class="chapter-name">{item.chapter.name}</span>
{#if pages > 0}
<div class="progress-row">
<div class="progress-wrap">
<div class="progress-bar" class:progress-error={isError} style="width:{pct}%"></div>
</div>
<span class="pages-label">
{#if isActive}
{prog.done}/{prog.total}
{:else if isError}
failed · {item.tries} {item.tries === 1 ? "try" : "tries"}
{:else}
{prog.total}p
{/if}
</span>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label" class:state-error={isError}>{item.state}</span>
<div class="actions">
{#if isError}
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
</button>
{/if}
{#if !isActive}
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</div>
</div>
</div>
<style>
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
cursor: default;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
.row.row-active { border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
.row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
.row-active .progress-bar { opacity: 1; }
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
.actions { display: flex; align-items: center; gap: 2px; }
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.action-btn:disabled { opacity: 0.25; cursor: default; }
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); }
</style>
@@ -1,98 +0,0 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import DownloadItem from "./DownloadItem.svelte";
import type { DownloadQueueItem } from "@types/index";
interface Props {
queue: DownloadQueueItem[];
loading: boolean;
isRunning: boolean;
dequeueing: Set<number>;
selected: Set<number>;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
}: Props = $props();
</script>
{#if loading}
<div class="list">
{#each Array(5) as _, i (i)}
<div class="sk-row">
<div class="sk-thumb skeleton"></div>
<div class="sk-info">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="sk-progress-row">
<div class="skeleton sk-bar"></div>
<div class="skeleton sk-pages"></div>
</div>
</div>
<div class="sk-right">
<div class="skeleton sk-state"></div>
<div class="sk-actions">
<div class="skeleton sk-btn"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if queue.length === 0}
<div class="empty">Queue is empty.</div>
{:else}
<div class="list">
{#each queue as item, i (item.chapter.id)}
<DownloadItem
{item}
isActive={i === 0 && isRunning}
isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)}
{onRemove}
{onRetry}
{onReorder}
{onReorderEdge}
{onSelect}
/>
{/each}
</div>
{/if}
<style>
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.sk-bar { flex: 1; height: 2px; }
.sk-pages { width: 28px; height: 9px; }
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.sk-state { width: 54px; height: 9px; }
.sk-actions { display: flex; gap: 2px; }
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
</style>
@@ -1,230 +0,0 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte";
import { formatEta } from "../lib/downloadQueue";
import { onMount } from "svelte";
onMount(() => {
downloadStore.poll();
});
let selectAnchor = $state<number | null>(null);
let moveBy = $state(1);
const selectedErrorCount = $derived(
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
);
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
const ctrl = e.ctrlKey || e.metaKey;
if (e.shiftKey && selectAnchor !== null) {
downloadStore.selectRange(selectAnchor, chapterId);
} else if (ctrl) {
downloadStore.toggleSelect(chapterId);
selectAnchor = chapterId;
} else {
if (downloadStore.selected.size > 1) {
downloadStore.toggleSelect(chapterId);
selectAnchor = chapterId;
} else if (downloadStore.selected.size === 1 && downloadStore.selected.has(chapterId)) {
downloadStore.clearSelection();
selectAnchor = null;
} else {
downloadStore.selectOnly(chapterId);
selectAnchor = chapterId;
}
}
}
function handleClickOff() {
if (downloadStore.selected.size > 0) {
downloadStore.clearSelection();
selectAnchor = null;
}
}
function clearSelection() {
downloadStore.clearSelection();
selectAnchor = null;
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button
class="icon-btn"
class:active={downloadStore.autoRetryEnabled}
onclick={() => downloadStore.toggleAutoRetry()}
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
>
<Repeat size={14} weight="regular" />
</button>
{#if downloadStore.hasErrored}
<button
class="icon-btn"
onclick={() => downloadStore.retryAllErrored()}
disabled={downloadStore.batchWorking}
title="Retry all errored"
>
{#if downloadStore.batchWorking}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowClockwise size={14} weight="bold" />
{/if}
</button>
{/if}
<button
class="icon-btn"
class:active={downloadStore.toastsEnabled}
onclick={() => downloadStore.toggleToasts()}
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
>
{#if downloadStore.toastsEnabled}
<Bell size={14} weight="regular" />
{:else}
<BellSlash size={14} weight="regular" />
{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.togglingPlay}
onclick={() => downloadStore.togglePlay()}
disabled={downloadStore.togglingPlay || (downloadStore.queue.length === 0 && !downloadStore.isRunning)}
title={downloadStore.isRunning ? "Pause" : "Resume"}
>
{#if downloadStore.togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if downloadStore.isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.clearing}
onclick={() => downloadStore.clear()}
disabled={downloadStore.clearing || downloadStore.queue.length === 0}
title="Clear queue"
>
{#if downloadStore.clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
</button>
</div>
</div>
<div class="bar-wrap">
<div class="status-bar" role="none">
<div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text">
{downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"}
</span>
{#if downloadStore.selected.size > 0}
<div class="sel-controls">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
<ArrowLineUp size={12} weight="bold" />
</button>
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
<CaretUp size={12} weight="bold" />
</button>
<input
class="move-input"
type="number"
min="1"
bind:value={moveBy}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
/>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
<CaretDown size={12} weight="bold" />
</button>
</div>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
<ArrowLineDown size={12} weight="bold" />
</button>
{#if selectedErrorCount > 0}
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
<ArrowClockwise size={12} weight="bold" />
</button>
{/if}
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
<X size={12} weight="bold" />
</button>
</div>
<div class="bar-sep"></div>
<span class="status-count">{downloadStore.selected.size} selected</span>
{:else}
<div class="status-right">
{#if downloadStore.isRunning && downloadStore.eta !== null}
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
{/if}
<span class="status-count">{downloadStore.queue.length} queued</span>
</div>
{/if}
</div>
</div>
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
<DownloadQueue
queue={downloadStore.queue}
loading={downloadStore.loading}
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
selected={downloadStore.selected}
onRemove={(id) => downloadStore.dequeue(id)}
onRetry={(id) => downloadStore.retryOne(id)}
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
onSelect={handleSelect}
/>
</div>
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; gap: var(--sp-2); }
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
.status-bar { cursor: default; }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
.move-input:focus { color: var(--text-primary); }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
-2
View File
@@ -1,2 +0,0 @@
export { downloadStore } from "./store/downloadState.svelte";
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
-39
View File
@@ -1,39 +0,0 @@
import type { DownloadQueueItem } from "@types/index";
const RETRY_DELAY_MS = 20_000;
export interface AutoRetryHandle {
stop: () => void;
}
export function startAutoRetry(
getQueue: () => DownloadQueueItem[],
isRunning: () => boolean,
retryErrored: () => Promise<void>,
): AutoRetryHandle {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
if (stopped) return;
const queue = getQueue();
const errored = queue.filter(i => i.state === "ERROR");
const active = queue.filter(i => i.state !== "ERROR");
if (errored.length > 0 && active.length === 0 && !isRunning()) {
await retryErrored().catch(() => {});
}
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
}
timer = setTimeout(tick, RETRY_DELAY_MS);
return {
stop() {
stopped = true;
if (timer !== null) { clearTimeout(timer); timer = null; }
},
};
}
@@ -1,84 +0,0 @@
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
return queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}));
}
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
return queue.filter((i) => i.chapter.id !== chapterId);
}
export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set<number>): DownloadQueueItem[] {
return queue.filter((i) => !chapterIds.has(i.chapter.id));
}
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
return queue.filter((i) => i.state === "ERROR");
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
export interface SpeedSample {
ts: number;
progress: number;
pages: number;
}
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
if (!prev) return null;
const dt = (current.ts - prev.ts) / 1000;
if (dt <= 0) return null;
const prevDone = Math.round(prev.progress * prev.pages);
const curDone = Math.round(current.progress * current.pages);
const delta = curDone - prevDone;
if (delta <= 0) return null;
return delta / dt;
}
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
if (pagesPerSec <= 0 || queue.length === 0) return null;
let remaining = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
remaining += pages - Math.round(item.progress * pages);
}
return remaining / pagesPerSec;
}
export function reorderSelectedToEdge(
queue: DownloadQueueItem[],
selected: Set<number>,
edge: "top" | "bottom",
): DownloadQueueItem[] {
const pinned = queue.filter((i) => selected.has(i.chapter.id));
const rest = queue.filter((i) => !selected.has(i.chapter.id));
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
const AVG_BYTES_PER_PAGE = 1_500_000;
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
let total = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
const remaining = pages - Math.round(item.progress * pages);
total += remaining * AVG_BYTES_PER_PAGE;
}
return total;
}
export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
return `~${(seconds / 3600).toFixed(1)}h`;
}
@@ -1,407 +0,0 @@
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries";
import {
START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER,
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
} from "@api/mutations";
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
type SpeedSample,
} from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
import { invoke } from "@tauri-apps/api/core";
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
storageWarning: boolean = $state(false);
private freeBytes: number | null = null;
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() {
const next = !this.toastsEnabled;
updateSettings({ downloadToastsEnabled: next });
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
updateSettings({ downloadAutoRetry: false });
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else {
updateSettings({ downloadAutoRetry: true });
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
}
}
detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return;
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
for (const item of this.prevQueue) {
if (item.state !== "DOWNLOADING") continue;
const nextItem = nextMap.get(item.chapter.id);
const manga = item.chapter.manga;
const label = manga ? `${manga.title}${item.chapter.name}` : item.chapter.name;
if (!nextItem) {
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
} else if (nextItem.state === "ERROR") {
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
}
}
this.prevQueue = next.slice();
}
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
this.updateSpeed(ds);
this.fetchFreeBytes(ds);
}
private async fetchFreeBytes(ds: DownloadStatus) {
const path = store.settings.serverDownloadsPath ?? "";
if (!path) return;
try {
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
this.freeBytes = info.free_bytes;
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
} catch { }
}
private confirmStorageOverrun(): Promise<boolean> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
const panel = document.createElement("div");
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
panel.innerHTML = `
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
</div>
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
The download queue is estimated to exceed 95% of your available storage. Download anyway?
</p>
</div>
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
</div>
`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
function finish(result: boolean) { backdrop.remove(); resolve(result); }
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
});
}
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
if (this.freeBytes === null) return true;
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
return this.confirmStorageOverrun();
}
private updateSpeed(ds: DownloadStatus) {
const active = ds.queue[0];
if (!active || active.state !== "DOWNLOADING") {
this.lastSample = null;
this.pagesPerSec = null;
this.eta = null;
return;
}
const sample: SpeedSample = {
ts: Date.now(),
progress: active.progress,
pages: active.chapter.pageCount ?? 0,
};
const speed = calcSpeed(this.lastSample, sample);
this.lastSample = sample;
if (speed !== null) {
this.pagesPerSec = speed;
this.eta = estimateEta(speed, ds.queue);
}
}
async poll() {
if (boot.sessionExpired) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => { this.loading = false; });
}
async togglePlay() {
if (this.togglingPlay) return;
this.togglingPlay = true;
const wasRunning = this.isRunning;
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
this.applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
this.applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); this.poll(); }
finally {
this.togglingPlay = false;
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
}
}
async clear() {
if (this.clearing) return;
this.clearing = true;
this.selected = new Set();
if (this.status) this.status = { ...this.status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus);
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; }
}
async dequeue(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
this.selected.delete(chapterId);
this.selected = new Set(this.selected);
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async dequeueSelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected];
if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) };
this.selected = new Set();
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
this.poll();
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async enqueue(chapterId: number): Promise<boolean> {
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
if (!(await this.guardStorage(projected))) return false;
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); }
return true;
}
async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
if (!(await this.guardStorage(projected))) { this.poll(); return; }
await gql(ENQUEUE_DOWNLOAD, { chapterId });
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async retryAllErrored() {
if (this.batchWorking || !this.hasErrored) return;
this.batchWorking = true;
const ids = [...this.erroredIds];
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async retrySelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
this.selected = new Set();
try {
if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorder(chapterId: number, direction: "up" | "down") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= this.queue.length) return;
const newQueue = [...this.queue];
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelected(direction: "up" | "down") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const queue = [...this.queue];
const selectedIndices = queue
.map((item, i) => ({ id: item.chapter.id, i }))
.filter(({ id }) => this.selected.has(id))
.map(({ i }) => i)
.sort((a, b) => direction === "up" ? a - b : b - a);
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
const newQueue = [...queue];
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= newQueue.length) break;
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
}
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= queue.length) break;
const chapterId = queue[idx].chapter.id;
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const first = this.isRunning ? 1 : 0;
const active = this.queue.slice(0, first);
const moveable = this.queue.slice(first);
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
const newQueue = edge === "top"
? [...active, ...pinned, ...rest]
: [...active, ...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
);
}
} else {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
);
}
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) {
const next = new Set(this.selected);
if (next.has(chapterId)) next.delete(chapterId);
else next.add(chapterId);
this.selected = next;
}
selectRange(fromId: number, toId: number) {
const ids = this.queue.map((i) => i.chapter.id);
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
if (a === -1 || b === -1) return;
const [lo, hi] = a < b ? [a, b] : [b, a];
const next = new Set(this.selected);
for (let i = lo; i <= hi; i++) next.add(ids[i]);
this.selected = next;
}
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
clearSelection() { this.selected = new Set(); }
}
export const downloadStore = new DownloadStore();

Some files were not shown because too many files have changed in this diff Show More