mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74790c3a0 | |||
| 0e93908bb2 | |||
| 074147f64f | |||
| f91b46cfa5 | |||
| 71ee4052f3 | |||
| 5e2114810e | |||
| b3fca70f27 | |||
| 68f25a2ea7 | |||
| 3d6b6430ed | |||
| 54307d4411 | |||
| f8f080eff3 | |||
| f41f8a9c22 | |||
| 8cef79b2b4 |
@@ -1,78 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Something isn't working as expected
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to report a bug. The more detail you include, the faster it gets fixed.
|
|
||||||
You can use the **Report a Bug** button in **Settings → About** to pre-fill most of this automatically.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: What's broken? A clear, concise summary.
|
|
||||||
placeholder: "e.g. Library card stats don't appear even with 'Always show' enabled"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
description: Exact steps to trigger the bug.
|
|
||||||
placeholder: |
|
|
||||||
1. Open Settings → Library
|
|
||||||
2. Enable "Always show card stats"
|
|
||||||
3. Return to Library
|
|
||||||
4. Unread counts are not visible
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
placeholder: "Unread and download counts should be permanently visible on manga cards"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: Actual Behavior
|
|
||||||
placeholder: "Counts only appear on hover, or not at all"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: environment
|
|
||||||
attributes:
|
|
||||||
label: Environment
|
|
||||||
description: Copy this from Settings → About → Report a Bug, or fill in manually.
|
|
||||||
placeholder: |
|
|
||||||
- Moku Version: v0.9.4
|
|
||||||
- Platform: Windows / macOS / Linux / Web
|
|
||||||
- OS Version: Windows 11 24H2
|
|
||||||
- Server: Suwayomi v2.2.2196
|
|
||||||
- Server URL: localhost:4567
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: settings
|
|
||||||
attributes:
|
|
||||||
label: Relevant Settings
|
|
||||||
description: Settings related to the bug (auto-filled by the in-app reporter, or paste manually).
|
|
||||||
placeholder: |
|
|
||||||
libraryStatsAlways: true
|
|
||||||
libraryCropCovers: true
|
|
||||||
libraryPageSize: 48
|
|
||||||
render: yaml
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Screenshots, screen recordings, console errors, anything else helpful.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Discussions (Questions & Support)
|
|
||||||
url: https://github.com/moku-project/Moku/discussions
|
|
||||||
about: Not a bug? Ask questions and get help here.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Suggest an improvement or new feature
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Got an idea? Describe what you want and why it would be useful.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: Problem / Motivation
|
|
||||||
description: What's the gap or frustration this would address?
|
|
||||||
placeholder: "e.g. There's no way to bulk-mark chapters as read without opening each series"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: Proposed Solution
|
|
||||||
description: What would you like to see?
|
|
||||||
placeholder: "A 'Mark all read' option in the series long-press context menu"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives Considered
|
|
||||||
description: Any workarounds you've tried, or other ways this could be solved.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: environment
|
|
||||||
attributes:
|
|
||||||
label: Environment
|
|
||||||
description: Optional — useful if this is platform-specific.
|
|
||||||
placeholder: |
|
|
||||||
- Moku Version: v0.9.4
|
|
||||||
- Platform: Windows / macOS / Linux / Web
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Mockups, references, examples from other apps, etc.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Sourced by CI jobs that need versions from nix/versions.nix.
|
|
||||||
# Usage: source .github/read_versions.sh
|
|
||||||
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
|
||||||
|
|
||||||
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
|
||||||
_t=$(cat "$_nix")
|
|
||||||
|
|
||||||
_pick() { echo "$_t" | grep -oP "${1}\s*=\s*\"\K[^\"]+"; }
|
|
||||||
|
|
||||||
export MOKU_VERSION=$(_pick "moku")
|
|
||||||
export SUWA_VERSION=$(_pick "version")
|
|
||||||
export SUWA_HASH_WINDOWS=$(_pick "windowsHash")
|
|
||||||
export SUWA_HASH_LINUX=$(_pick "linuxHash")
|
|
||||||
export SUWA_HASH_MACOS_ARM64=$(_pick "macosArm64Hash")
|
|
||||||
export SUWA_HASH_MACOS_X64=$(_pick "macosX64Hash")
|
|
||||||
|
|
||||||
unset _nix _t
|
|
||||||
unset -f _pick
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Build Flatpak
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to build (e.g. 0.9.0)"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flatpak:
|
|
||||||
name: Build Flatpak bundle
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Free up disk space
|
|
||||||
run: |
|
|
||||||
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
|
|
||||||
sudo docker image prune -af || true
|
|
||||||
|
|
||||||
- name: Install flatpak tooling
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y flatpak flatpak-builder
|
|
||||||
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
|
||||||
|
|
||||||
- name: Cache flatpak runtimes/SDKs
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.local/share/flatpak
|
|
||||||
key: flatpak-runtimes-gnome48-rust-stable
|
|
||||||
|
|
||||||
- name: Install runtime, SDK and rust-stable extension
|
|
||||||
run: |
|
|
||||||
flatpak --user install -y --noninteractive flathub \
|
|
||||||
org.gnome.Platform//48 \
|
|
||||||
org.gnome.Sdk//48 \
|
|
||||||
org.freedesktop.Sdk.Extension.rust-stable//48
|
|
||||||
|
|
||||||
- name: Build flatpak
|
|
||||||
run: |
|
|
||||||
rm -rf build-dir repo
|
|
||||||
flatpak-builder \
|
|
||||||
--user \
|
|
||||||
--install-deps-from=flathub \
|
|
||||||
--repo=repo \
|
|
||||||
--force-clean \
|
|
||||||
build-dir \
|
|
||||||
io.github.moku_project.Moku.yml
|
|
||||||
|
|
||||||
- name: Bundle flatpak
|
|
||||||
run: |
|
|
||||||
flatpak build-bundle \
|
|
||||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
|
|
||||||
repo \
|
|
||||||
moku.flatpak \
|
|
||||||
io.github.moku_project.Moku
|
|
||||||
|
|
||||||
- name: Upload Flatpak artifact to release
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
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${{ github.event.inputs.version }}") | .id' | head -1)
|
|
||||||
[ -n "$RELEASE_ID" ] && break
|
|
||||||
echo "Waiting for release... attempt $i"; sleep 15
|
|
||||||
done
|
|
||||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"moku.flatpak" \
|
|
||||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku.flatpak"
|
|
||||||
@@ -16,15 +16,24 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build:static
|
- name: Install dependencies
|
||||||
- uses: actions/upload-artifact@v4
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-dist-linux
|
name: frontend-dist-linux
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -34,56 +43,77 @@ jobs:
|
|||||||
name: Tauri (Linux x64)
|
name: Tauri (Linux x64)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- name: Download frontend dist
|
||||||
with: { name: frontend-dist-linux, path: dist/ }
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
- name: Read versions
|
name: frontend-dist-linux
|
||||||
run: |
|
path: dist/
|
||||||
source .github/read_versions.sh
|
|
||||||
echo "MOKU_VERSION=$MOKU_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "SUWA_HASH=$SUWA_HASH_LINUX" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libfuse2
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- name: Install Rust
|
||||||
with: { targets: x86_64-unknown-linux-gnu }
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Rust cache
|
||||||
with: { workspaces: src-tauri }
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Download Suwayomi (Linux x64)
|
- name: Download Suwayomi (Linux x64)
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-linux-x64.tar.gz" \
|
"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
|
-o suwayomi-linux.tar.gz
|
||||||
echo "${SUWA_HASH} suwayomi-linux.tar.gz" | sha256sum -c -
|
|
||||||
|
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
|
||||||
|
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
for f in suwayomi-extracted/bin/Suwayomi-Server.jar \
|
|
||||||
suwayomi-extracted/jre/bin/java \
|
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
||||||
suwayomi-extracted/bin/catch_abort.so; do
|
JAVA="suwayomi-extracted/jre/bin/java"
|
||||||
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
|
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
|
done
|
||||||
|
|
||||||
|
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
|
||||||
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||||
|
|
||||||
@@ -99,30 +129,43 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||||
env: { NO_STRIP: "true" }
|
env:
|
||||||
|
NO_STRIP: "true"
|
||||||
|
|
||||||
- name: Upload Linux artifacts to release
|
- name: Upload Linux artifacts to release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 12); do
|
for i in $(seq 1 12); do
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
| jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||||
[ -n "$RELEASE_ID" ] && break
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
echo "Waiting for release... attempt $i"; sleep 15
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
done
|
done
|
||||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
|
||||||
|
|
||||||
upload() {
|
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 \
|
curl -s -X POST \
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @"$1" \
|
--data-binary @"$file" \
|
||||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
"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)
|
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)
|
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||||
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
|
|
||||||
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
|
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
||||||
|
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
|
||||||
@@ -4,7 +4,7 @@ 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
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -16,16 +16,28 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build:static
|
- name: Install dependencies
|
||||||
- uses: actions/upload-artifact@v4
|
run: pnpm install --frozen-lockfile
|
||||||
with: { name: frontend-dist, path: dist/, retention-days: 1 }
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
@@ -34,103 +46,149 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- name: Download frontend dist
|
||||||
with: { name: frontend-dist, path: dist/ }
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
- name: Read versions
|
|
||||||
run: |
|
|
||||||
source .github/read_versions.sh
|
|
||||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "SUWA_HASH_ARM64=$SUWA_HASH_MACOS_ARM64" >> $GITHUB_ENV
|
|
||||||
echo "SUWA_HASH_X64=$SUWA_HASH_MACOS_X64" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
with:
|
||||||
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Install Rust
|
||||||
with: { workspaces: src-tauri }
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Download Suwayomi binaries
|
- name: Download Suwayomi binaries
|
||||||
run: |
|
run: |
|
||||||
dl() {
|
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/v${SUWA_VERSION}/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${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}"
|
||||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
}
|
}
|
||||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
|
|
||||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||||
|
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||||
|
"suwayomi-arm64"
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||||
|
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||||
|
"suwayomi-x64"
|
||||||
|
|
||||||
- name: Stage Suwayomi sidecars
|
- name: Stage Suwayomi sidecars
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
stage() {
|
|
||||||
local srcdir="$1" arch="$2"
|
stage_arch() {
|
||||||
|
local srcdir="$1"
|
||||||
|
local arch="$2"
|
||||||
|
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
|
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
|
|
||||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
|
||||||
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
if [ -z "$JAR" ]; then
|
||||||
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
find "$srcdir" -type f | head -30
|
||||||
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
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 suwayomi-arm64 aarch64-apple-darwin
|
|
||||||
stage suwayomi-x64 x86_64-apple-darwin
|
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
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: Build Tauri app (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 src-tauri/binaries/suwayomi-bundle
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
- name: Build Tauri app (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 src-tauri/binaries/suwayomi-bundle
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
- name: Upload macOS artifacts to release
|
- name: Upload macOS artifacts to release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
|
# Wait for the Windows workflow to have created the draft release
|
||||||
for i in $(seq 1 12); do
|
for i in $(seq 1 12); do
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
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)
|
||||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
echo "Waiting for release to exist... attempt $i"
|
||||||
[ -n "$RELEASE_ID" ] && break
|
sleep 15
|
||||||
echo "Waiting for release... attempt $i"; sleep 15
|
|
||||||
done
|
done
|
||||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
|
||||||
|
|
||||||
upload() {
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
curl -s -X POST \
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
exit 1
|
||||||
-H "Content-Type: application/octet-stream" \
|
fi
|
||||||
--data-binary @"$1" \
|
|
||||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
ARM64=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
X64=$(find src-tauri/target/x86_64-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" ] && upload "$ARM64" "moku-macos-arm64-${{ github.event.inputs.version }}.dmg"
|
|
||||||
[ -n "$X64" ] && upload "$X64" "moku-macos-x64-${{ github.event.inputs.version }}.dmg"
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
name: Build Static WebUI
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to build (e.g. 0.9.0)"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build static 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
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build:static
|
|
||||||
|
|
||||||
- name: Zip static build
|
|
||||||
run: |
|
|
||||||
cd dist
|
|
||||||
zip -r "../moku-webui-${{ github.event.inputs.version }}.zip" .
|
|
||||||
|
|
||||||
- name: Upload WebUI artifact to release
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
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${{ github.event.inputs.version }}") | .id' | head -1)
|
|
||||||
[ -n "$RELEASE_ID" ] && break
|
|
||||||
echo "Waiting for release... attempt $i"; sleep 15
|
|
||||||
done
|
|
||||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
||||||
-H "Content-Type: application/zip" \
|
|
||||||
--data-binary @"moku-webui-${{ github.event.inputs.version }}.zip" \
|
|
||||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku-webui-${{ github.event.inputs.version }}.zip"
|
|
||||||
@@ -16,81 +16,120 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm build:static
|
- name: Install dependencies
|
||||||
- uses: actions/upload-artifact@v4
|
run: pnpm install --frozen-lockfile
|
||||||
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (Windows x64)
|
name: Tauri (Windows x64)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- name: Download frontend dist
|
||||||
with: { name: frontend-dist-windows, path: dist/ }
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
- name: Read versions
|
- name: Install Rust
|
||||||
shell: bash
|
uses: dtolnay/rust-toolchain@stable
|
||||||
run: |
|
with:
|
||||||
source .github/read_versions.sh
|
targets: x86_64-pc-windows-msvc
|
||||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- name: Rust cache
|
||||||
with: { targets: x86_64-pc-windows-msvc }
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
- uses: Swatinem/rust-cache@v2
|
workspaces: src-tauri
|
||||||
with: { workspaces: src-tauri }
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with: { version: latest }
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Download Suwayomi (Windows x64)
|
- name: Download Suwayomi (Windows x64)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
- name: Extract Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
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/
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
if [ -z "$JAVA" ]; then
|
||||||
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
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
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Validate staging
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
||||||
|
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
||||||
|
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
||||||
|
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
||||||
|
echo "Staging OK"
|
||||||
|
|
||||||
- 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
|
- name: Delete existing draft release if present
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -99,10 +138,14 @@ jobs:
|
|||||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
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 }}"
|
"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
|
fi
|
||||||
|
|
||||||
- name: Build Tauri app + create draft release
|
- name: Build Tauri app + create draft release
|
||||||
@@ -115,10 +158,10 @@ jobs:
|
|||||||
releaseBody: |
|
releaseBody: |
|
||||||
Moku v${{ github.event.inputs.version }}
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
**Windows:** `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
**macOS arm64:** `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
**macOS x64:** `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
**Linux:** `moku.flatpak`
|
**Linux:** Download `moku.flatpak`
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -6,6 +6,7 @@ dist-tauri/
|
|||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
notes/
|
||||||
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.10.0
|
pkgver=0.9.4
|
||||||
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')
|
||||||
|
|||||||
@@ -1,173 +1,42 @@
|
|||||||
<div align="center">
|
# sv
|
||||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
[](https://github.com/moku-project/Moku/releases/latest)
|
## Creating a project
|
||||||

|
|
||||||
[](https://github.com/moku-project/Moku)
|
|
||||||
[](https://discord.gg/x97hj8zR72)
|
|
||||||
|
|
||||||
</div>
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
<br/>
|
```sh
|
||||||
|
# create a new project
|
||||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
npx sv create my-app
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
|
||||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
|
||||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
|
||||||
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
|
||||||
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<a href="docs/screenshots">View all screenshots →</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
|
||||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
|
||||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
|
||||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
|
||||||
- **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
|
|
||||||
- **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
|
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
|
||||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
**winget:**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install Moku.Moku
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
```sh
|
||||||
|
# recreate this project
|
||||||
### Linux (Flatpak, recommended)
|
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
||||||
|
|
||||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flatpak install io.github.moku_app.Moku
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
## Developing
|
||||||
|
|
||||||
```bash
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
flatpak install moku.flatpak
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nix
|
## Building
|
||||||
|
|
||||||
```bash
|
To create a production version of your app:
|
||||||
nix run github:moku-project/Moku
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
```nix
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
inputs.moku.url = "github:moku-project/Moku";
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
|
||||||
|
|
||||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
|
||||||
> ```bash
|
|
||||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
|
||||||
|
|
||||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/moku-project/Moku
|
|
||||||
cd Moku
|
|
||||||
pnpm install
|
|
||||||
pnpm tauri:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with Nix:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix develop
|
|
||||||
pnpm install
|
|
||||||
pnpm tauri:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
|
||||||
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
|
||||||
| [Vite 8](https://vitejs.dev) | Frontend bundler |
|
|
||||||
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Community
|
|
||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
|
||||||
|
|
||||||
[](https://discord.gg/x97hj8zR72)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
Revival of the TODO List!!!!!
|
Major Revisions:
|
||||||
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
|
- Moku-Share allows exporting of Manga
|
||||||
|
- Compressed Format (Storage)
|
||||||
|
- 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)
|
||||||
|
|
||||||
- Reminder to Completely Test Settings
|
Priority Bugs:
|
||||||
|
- Fix Library-Refresh System (TESTING)
|
||||||
|
|
||||||
|
- Suwayomi RESET
|
||||||
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
|
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 Auto-Link De-dupe for Images
|
||||||
|
- Optimize Auto-Link Latency (IP)
|
||||||
|
|
||||||
|
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)
|
||||||
|
- Note User's have to always install extensions manually
|
||||||
|
- Create "Missing Source" for Manga
|
||||||
|
|
||||||
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
|
|
||||||
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
|
Notes from last time:
|
||||||
|
|||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
query GetDownloadStatus {
|
||||||
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
|
export * from "./updater";
|
||||||
|
export * from "./meta";
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const GET_META = `
|
||||||
|
query GetMeta($key: String!) {
|
||||||
|
meta(key: $key) {
|
||||||
|
key value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_METAS = `
|
||||||
|
query GetMetas {
|
||||||
|
metas {
|
||||||
|
nodes { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./selectPortal";
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './sort';
|
||||||
|
export * from './filter';
|
||||||
|
export * from './paginate';
|
||||||
|
export * from './search';
|
||||||
|
export * from './queue';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './fetchWithRetry';
|
||||||
|
export * from './batchRequests';
|
||||||
|
export * from './createPaginatedQuery';
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+134
@@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './memoryCache';
|
||||||
|
export * from './pageCache';
|
||||||
|
export * from './imageCache';
|
||||||
|
export * from './queryCache';
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
+6
-15
@@ -1,16 +1,7 @@
|
|||||||
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
import { gql, getServerUrl } from "@api/client";
|
||||||
import { dedupeRequest } from "$lib/core/async/batchRequests";
|
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||||
import { FETCH_CHAPTER_PAGES } from "$lib/server-adapters/suwayomi/chapters";
|
import { dedupeRequest } from "@core/async/batchRequests";
|
||||||
|
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||||
type GqlFn = <T>(query: string, vars?: Record<string, unknown>, signal?: AbortSignal) => Promise<T>;
|
|
||||||
|
|
||||||
let _gql: GqlFn;
|
|
||||||
let _getServerUrl: () => string;
|
|
||||||
|
|
||||||
export function initPageCache(gql: GqlFn, getServerUrl: () => string): void {
|
|
||||||
_gql = gql;
|
|
||||||
_getServerUrl = getServerUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
@@ -41,9 +32,9 @@ export function fetchPages(
|
|||||||
|
|
||||||
if (!inflight.has(chapterId)) {
|
if (!inflight.has(chapterId)) {
|
||||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||||
_gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||||
.then(d => {
|
.then(d => {
|
||||||
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${_getServerUrl()}${p}`);
|
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
|
||||||
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||||
pageCache.set(chapterId, urls);
|
pageCache.set(chapterId, urls);
|
||||||
return urls;
|
return urls;
|
||||||
Vendored
+241
@@ -0,0 +1,241 @@
|
|||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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] ?? [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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(" · ") };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
||||||
|
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
||||||
|
export type { CoverCandidate } from "./coverResolver";
|
||||||
|
export { autoLinkLibrary } from "./autoLink";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
||||||
|
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
||||||
|
export type { Keybinds } from "./defaultBinds";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './idle';
|
||||||
|
export * from './zoom';
|
||||||
|
export * from './touchscreen';
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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);
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeDown {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.97); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
||||||
|
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
||||||
|
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
||||||
|
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
||||||
|
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
||||||
|
.anim-spin { animation: spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import "./reset.css";
|
||||||
|
@import "./animations.css";
|
||||||
|
@import "./scrollbars.css";
|
||||||
|
@import "./typography.css";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
*, *::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; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
* {
|
||||||
|
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; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import "./original.css";
|
||||||
|
@import "./dark.css";
|
||||||
|
@import "./light.css";
|
||||||
|
@import "./midnight.css";
|
||||||
|
@import "./warm.css";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
: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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@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";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--t-fast: 0.08s ease;
|
||||||
|
--t-base: 0.14s ease;
|
||||||
|
--t-slow: 0.22s ease;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:root {
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 5px;
|
||||||
|
--radius-lg: 7px;
|
||||||
|
--radius-xl: 10px;
|
||||||
|
--radius-2xl: 14px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:root {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--z-reader: 50;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-settings: 150;
|
||||||
|
}
|
||||||
+78
-68
@@ -1,37 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { gql } from "@api/client";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { setPreviewManga } from "$lib/state/series.svelte";
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
|
||||||
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
|
||||||
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
|
||||||
import { ArrowLeftIcon, BookmarkSimpleIcon, FolderSimplePlusIcon, FolderIcon, CircleNotchIcon } from "phosphor-svelte";
|
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
|
||||||
import type { Manga, Source, Category } from "$lib/types";
|
import type { Manga, Source, Category } from "@types/index";
|
||||||
|
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||||
import {
|
import {
|
||||||
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
||||||
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
||||||
} from "$lib/components/browse/lib/searchFilter";
|
} from "@features/discover/lib/searchFilter";
|
||||||
|
|
||||||
interface MenuItem {
|
const prevNavPage = store.navPage;
|
||||||
label: string;
|
const tags = $derived(parseTags(store.genreFilter));
|
||||||
icon?: any;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
separator?: never;
|
|
||||||
children?: MenuEntry[];
|
|
||||||
}
|
|
||||||
interface MenuSeparator { separator: true }
|
|
||||||
type MenuEntry = MenuItem | MenuSeparator;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
genre: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
let { genre, onBack }: Props = $props();
|
|
||||||
|
|
||||||
const tags = $derived(parseTags(genre));
|
|
||||||
const primaryTag = $derived(tags[0] ?? "");
|
const primaryTag = $derived(tags[0] ?? "");
|
||||||
const label = $derived(tagsLabel(tags));
|
const label = $derived(tagsLabel(tags));
|
||||||
|
|
||||||
@@ -49,9 +34,9 @@
|
|||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m as any, settingsState.settings));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m as any, settingsState.settings))]);
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
@@ -59,7 +44,7 @@
|
|||||||
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||||
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||||
|
|
||||||
$effect(() => { const f = genre; if (f) untrack(() => load(f)); });
|
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||||
|
|
||||||
async function load(filter: string) {
|
async function load(filter: string) {
|
||||||
abortCtrl?.abort();
|
abortCtrl?.abort();
|
||||||
@@ -71,30 +56,46 @@
|
|||||||
visibleCount = PAGE_SIZE;
|
visibleCount = PAGE_SIZE;
|
||||||
nextPageMap.clear();
|
nextPageMap.clear();
|
||||||
|
|
||||||
|
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||||
const t = parseTags(filter);
|
const t = parseTags(filter);
|
||||||
const pt = t[0] ?? "";
|
const pt = t[0] ?? "";
|
||||||
|
|
||||||
getAdapter().getMangaList({}).then((result: { items: Manga[] }) => {
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
if (!ctrl.signal.aborted) libraryManga = result.items;
|
Promise.all([
|
||||||
}).catch(() => {});
|
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(() => {});
|
||||||
|
|
||||||
getAdapter().getSources().then(async (allSources: Source[]) => {
|
cache.get(
|
||||||
if (ctrl.signal.aborted) return;
|
CACHE_KEYS.SOURCES,
|
||||||
const srcs = allSources.filter((s: Source) => s.id !== "0").slice(0, MAX_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;
|
sources = srcs;
|
||||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||||
|
|
||||||
await runConcurrent(srcs, async (src: Source) => {
|
await runConcurrent(srcs, async (src) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
const ps = getPageSet(src.id, "SEARCH", t);
|
||||||
const pageItems: Manga[] = [];
|
const pageItems: Manga[] = [];
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||||
try {
|
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
result = await getAdapter().searchSource(src.id, pt, page, ctrl.signal);
|
pageKey,
|
||||||
} catch { break; }
|
() => 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;
|
if (!result || ctrl.signal.aborted) break;
|
||||||
const matching = t.length > 1 ? result.items.filter((m) => matchesAllTags(m, t)) : result.items;
|
ps.add(page);
|
||||||
|
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||||
pageItems.push(...matching);
|
pageItems.push(...matching);
|
||||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||||
@@ -119,16 +120,21 @@
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl = ctrl;
|
abortCtrl = ctrl;
|
||||||
try {
|
try {
|
||||||
await runConcurrent(srcs, async (src: Source) => {
|
await runConcurrent(srcs, async (src) => {
|
||||||
const page = nextPageMap.get(src.id)!;
|
const page = nextPageMap.get(src.id)!;
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||||
try {
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||||
result = await getAdapter().searchSource(src.id, primaryTag, page, ctrl.signal);
|
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
} catch { nextPageMap.set(src.id, -1); return; }
|
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;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
ps.add(page);
|
||||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
const matching = tags.length > 1 ? result.items.filter((m) => matchesAllTags(m, tags)) : result.items;
|
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,8 +147,8 @@
|
|||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
if (!catsLoaded) {
|
if (!catsLoaded) {
|
||||||
catsLoaded = true;
|
catsLoaded = true;
|
||||||
getAdapter().getCategories()
|
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||||
.then((cats: Category[]) => { categories = cats.filter((c: Category) => c.id !== 0); })
|
.then((d) => { categories = d.categories.nodes.filter((c) => c.id !== 0); })
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,31 +157,37 @@
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
icon: BookmarkSimpleIcon,
|
icon: BookmarkSimple,
|
||||||
disabled: m.inLibrary,
|
disabled: m.inLibrary,
|
||||||
onClick: () => getAdapter().addToLibrary(String(m.id))
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
|
.then(() => {
|
||||||
|
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
})
|
||||||
.catch(console.error),
|
.catch(console.error),
|
||||||
},
|
},
|
||||||
...(categories.length > 0 ? [
|
...(categories.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...categories.map((cat): MenuEntry => ({
|
...categories.map((cat): MenuEntry => ({
|
||||||
label: (cat.mangas ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
icon: FolderIcon,
|
icon: Folder,
|
||||||
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
|
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "New folder & add",
|
label: "New folder & add",
|
||||||
icon: FolderSimplePlusIcon,
|
icon: FolderSimplePlus,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const name = prompt("Folder name:");
|
const name = prompt("Folder name:");
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
const cat = await getAdapter().createCategory(name.trim()).catch(console.error);
|
const res = await gql<{ createCategory: { category: Category } }>(
|
||||||
if (cat) {
|
CREATE_CATEGORY, { name: name.trim() },
|
||||||
|
).catch(console.error);
|
||||||
|
if (res) {
|
||||||
|
const cat = res.createCategory.category;
|
||||||
categories = [...categories, cat];
|
categories = [...categories, cat];
|
||||||
await getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error);
|
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,8 +199,8 @@
|
|||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" onclick={onBack}>
|
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||||
<ArrowLeftIcon size={13} weight="light" /><span>Back</span>
|
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="title">{label}</span>
|
<span class="title">{label}</span>
|
||||||
{#if !loadingInitial || filtered.length > 0}
|
{#if !loadingInitial || filtered.length > 0}
|
||||||
@@ -215,7 +227,7 @@
|
|||||||
{#each visibleItems as m, i (m.id)}
|
{#each visibleItems as m, i (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
<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}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
@@ -224,7 +236,7 @@
|
|||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<div class="show-more-cell">
|
<div class="show-more-cell">
|
||||||
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||||
{#if loadingMore}<CircleNotchIcon size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -250,12 +262,10 @@
|
|||||||
.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); }
|
.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; }
|
: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); }
|
.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; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.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; }
|
.card-skeleton { padding: 0; }
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||||
@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; }
|
|
||||||
.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); }
|
.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-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 { 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); }
|
||||||
+80
-74
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { gql } from "@api/client";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "$lib/core/util";
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||||
import type { Manga, Source } from "$lib/types";
|
import { store } from "@store/state.svelte";
|
||||||
import type { CachedManga } from "$lib/components/browse/lib/searchFilter";
|
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 {
|
interface Props {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
@@ -16,8 +19,6 @@
|
|||||||
popularResults: (Manga & { _priority: number })[];
|
popularResults: (Manga & { _priority: number })[];
|
||||||
popularLoading: boolean;
|
popularLoading: boolean;
|
||||||
sourceCache: Map<number, CachedManga>;
|
sourceCache: Map<number, CachedManga>;
|
||||||
query: string;
|
|
||||||
onQueryChange: (q: string) => void;
|
|
||||||
onPrefillConsumed: () => void;
|
onPrefillConsumed: () => void;
|
||||||
onPreview: (m: Manga) => void;
|
onPreview: (m: Manga) => void;
|
||||||
}
|
}
|
||||||
@@ -25,20 +26,18 @@
|
|||||||
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||||
pendingPrefill, popularResults, popularLoading,
|
pendingPrefill, popularResults, popularLoading,
|
||||||
sourceCache,
|
sourceCache,
|
||||||
query, onQueryChange,
|
|
||||||
onPrefillConsumed, onPreview,
|
onPrefillConsumed, onPreview,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
|
let kw_query = $state("");
|
||||||
let kw_results: SourceResult[] = $state([]);
|
let kw_results: SourceResult[] = $state([]);
|
||||||
let kw_showAdvanced = $state(false);
|
let kw_showAdvanced = $state(false);
|
||||||
let kw_selectedLangs: Set<string> = $state(new Set());
|
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
let kw_abortCtrl: AbortController | null = null;
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let kw_localQuery = $state("");
|
|
||||||
let kw_pending = $state(false);
|
|
||||||
|
|
||||||
interface SourceResult {
|
interface SourceResult {
|
||||||
source: Source;
|
source: Source;
|
||||||
@@ -48,69 +47,76 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!allSources.length) return;
|
if (allSources.length) {
|
||||||
const available = new Set(allSources.map((s) => s.lang));
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
kw_selectedLangs = available.has(preferredLang)
|
kw_selectedLangs = available.has(preferredLang)
|
||||||
? new Set([preferredLang])
|
? new Set([preferredLang])
|
||||||
: new Set(availableLangs.slice(0, 1));
|
: new Set(availableLangs.slice(0, 1));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||||
const q = pendingPrefill;
|
const q = pendingPrefill;
|
||||||
onPrefillConsumed();
|
onPrefillConsumed();
|
||||||
kw_localQuery = q;
|
kw_query = q;
|
||||||
onQueryChange(q);
|
|
||||||
kwDoSearch(q);
|
kwDoSearch(q);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function kwHandleInput(value: string) {
|
$effect(() => {
|
||||||
kw_localQuery = value;
|
const q = kw_query;
|
||||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
|
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
||||||
kw_pending = true;
|
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
||||||
kw_debounceTimer = setTimeout(() => {
|
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
||||||
kw_pending = false;
|
|
||||||
onQueryChange(value);
|
|
||||||
kwDoSearch(value);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const kw_visibleSources = $derived.by(() => {
|
|
||||||
let srcs = allSources;
|
|
||||||
if (kw_selectedLangs.size > 0)
|
|
||||||
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
|
|
||||||
if (settingsState.settings.contentLevel !== "unrestricted")
|
|
||||||
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
|
|
||||||
return srcs;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
async function kwDoSearch(q: string) {
|
||||||
const trimmed = q.trim();
|
const trimmed = q.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const visible = kw_visibleSources;
|
const visible = kwGetVisibleSources();
|
||||||
if (!visible.length) return;
|
if (!visible.length) return;
|
||||||
|
|
||||||
kw_abortCtrl?.abort();
|
kw_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
kw_abortCtrl = ctrl;
|
kw_abortCtrl = ctrl;
|
||||||
|
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||||
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
kw_results = initial;
|
||||||
const idxOf = new Map(visible.map((src, i) => [src.id, i]));
|
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
|
||||||
|
await runConcurrent(visible, async (src) => {
|
||||||
await Promise.allSettled(visible.map(async (src) => {
|
|
||||||
const idx = idxOf.get(src.id)!;
|
|
||||||
try {
|
|
||||||
const result: { items: Manga[]; hasNextPage: boolean } = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
|
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const mangas = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
|
const idx = indexBySrcId.get(src.id)!;
|
||||||
kw_results[idx] = { ...kw_results[idx], mangas, loading: false };
|
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) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
kw_results[idx] = { ...kw_results[idx], loading: false, error: e.message ?? "Error" };
|
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) {
|
function kwToggleLang(lang: string) {
|
||||||
@@ -120,19 +126,16 @@
|
|||||||
kw_selectedLangs = next;
|
kw_selectedLangs = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kw_visibleCount = $derived(kw_visibleSources.length);
|
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||||
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
|
||||||
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
|
||||||
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
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 kw_flatResults = $derived.by(() => {
|
||||||
const all = kw_results.flatMap((r) =>
|
const all = kw_results.flatMap((r) =>
|
||||||
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
|
||||||
);
|
);
|
||||||
const deduped = dedupeMangaByTitle(
|
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
|
||||||
dedupeMangaById(all),
|
|
||||||
settingsState.settings.mangaLinks,
|
|
||||||
) as (Manga & { _sourceName?: string })[];
|
|
||||||
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,17 +152,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:this={kw_inputEl}
|
bind:this={kw_inputEl}
|
||||||
value={kw_localQuery}
|
bind:value={kw_query}
|
||||||
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
|
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
|
use:focusOnMount
|
||||||
/>
|
/>
|
||||||
{#if kw_pending || kw_anyLoading}
|
{#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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{:else if kw_localQuery}
|
{:else if kw_query}
|
||||||
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
|
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasMultipleLangs}
|
{#if hasMultipleLangs}
|
||||||
<button
|
<button
|
||||||
@@ -175,10 +178,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if kw_showAdvanced && hasMultipleLangs}
|
{#if hasMultipleLangs && kw_showAdvanced}
|
||||||
<div class="advancedPanel">
|
<div class="advancedPanel">
|
||||||
<div class="advancedHeader">
|
<div class="advancedHeader">
|
||||||
<span class="advancedTitle">LANGUAGES</span>
|
<span class="advancedTitle">Languages</span>
|
||||||
<div class="advancedActions">
|
<div class="advancedActions">
|
||||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||||
@@ -199,7 +202,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !kw_localQuery.trim()}
|
{#if !kw_query.trim()}
|
||||||
{#if popularLoading && popularResults.length === 0}
|
{#if popularLoading && popularResults.length === 0}
|
||||||
<div class="searchGrid">
|
<div class="searchGrid">
|
||||||
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
@@ -212,7 +215,7 @@
|
|||||||
{#each popularResults as m (m.id)}
|
{#each popularResults as m (m.id)}
|
||||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
<div class="srchCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||||
<div class="srchGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="srchFooter">
|
<div class="srchFooter">
|
||||||
@@ -241,20 +244,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if kw_pending}
|
|
||||||
<div class="searchGrid">
|
|
||||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if kw_flatResults.length > 0}
|
{#if kw_flatResults.length > 0}
|
||||||
<div class="searchHeader">
|
<div class="searchHeader">
|
||||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</span>
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchGrid">
|
<div class="searchGrid">
|
||||||
{#each kw_flatResults as m (m.id)}
|
{#each kw_flatResults as m (m.id)}
|
||||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
<div class="srchCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||||
<div class="srchGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="srchFooter">
|
<div class="srchFooter">
|
||||||
@@ -274,15 +273,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if kw_allDone && !kw_hasResults}
|
{:else if kw_allDone && !kw_hasResults}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
<p class="emptyText">No results for "{kw_query.trim()}"</p>
|
||||||
<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">No results for "{kw_localQuery.trim()}"</p>
|
|
||||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
.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 { 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); }
|
||||||
@@ -292,6 +292,7 @@
|
|||||||
.searchInput::placeholder { color: var(--text-faint); }
|
.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 { 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); }
|
.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 { 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); }
|
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
@@ -307,25 +308,30 @@
|
|||||||
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
.advancedDivider { height: 1px; background: var(--border-dim); }
|
.advancedDivider { height: 1px; background: var(--border-dim); }
|
||||||
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
.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; }
|
.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; }
|
.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; }
|
.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 { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
.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); }
|
.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; }
|
.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; }
|
.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; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
.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; }
|
.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; }
|
.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%; }
|
.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 } }
|
@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; }
|
.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); }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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) } }
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
</style>
|
</style>
|
||||||
+113
-90
@@ -1,44 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, untrack } from "svelte";
|
import { onDestroy, untrack } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { gql } from "@api/client";
|
||||||
import { goto } from "$app/navigation";
|
import { GET_SOURCES } from "@api/queries/extensions";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { FETCH_MANGA } from "@api/mutations/manga";
|
||||||
import { setPreviewManga } from "$lib/state/series.svelte";
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||||
import type { Manga, Source } from "$lib/types";
|
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 "$lib/components/browse/KeywordTab.svelte";
|
import KeywordTab from "./KeywordTab.svelte";
|
||||||
import TagTab from "$lib/components/browse/TagTab.svelte";
|
import TagTab from "./TagTab.svelte";
|
||||||
import SourceTab from "$lib/components/browse/SourceTab.svelte";
|
import SourceTab from "./SourceTab.svelte";
|
||||||
|
|
||||||
interface Props {
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
initialTab?: "keyword" | "tag" | "source";
|
|
||||||
preselectedSourceId?: string;
|
|
||||||
}
|
|
||||||
let { initialTab, preselectedSourceId }: Props = $props();
|
|
||||||
|
|
||||||
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
const TABS = ["keyword", "tag", "source"] as const;
|
||||||
|
|
||||||
type SearchTab = "keyword" | "tag" | "source";
|
|
||||||
|
|
||||||
const urlTab = $derived(($page.url.searchParams.get("tab") as SearchTab | null) ?? initialTab ?? "keyword");
|
|
||||||
const urlQuery = $derived($page.url.searchParams.get("q") ?? "");
|
|
||||||
|
|
||||||
function setTab(next: SearchTab) {
|
|
||||||
const u = new URL($page.url);
|
|
||||||
u.searchParams.set("tab", next);
|
|
||||||
goto(u.toString(), { replaceState: true, noScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setQuery(next: string) {
|
|
||||||
const u = new URL($page.url);
|
|
||||||
if (next) u.searchParams.set("q", next);
|
|
||||||
else u.searchParams.delete("q");
|
|
||||||
goto(u.toString(), { replaceState: true, noScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingPrefill = $state("");
|
|
||||||
|
|
||||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let tabIndicator = $state({ left: 0, width: 0 });
|
let tabIndicator = $state({ left: 0, width: 0 });
|
||||||
@@ -50,55 +33,66 @@
|
|||||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { urlTab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
const SEARCH_PAGES = 3;
|
const SEARCH_PAGES = 3;
|
||||||
const SEARCH_LIMIT = 200;
|
const SEARCH_LIMIT = 200;
|
||||||
const SEARCH_BATCH = 20;
|
const SEARCH_BATCH = 20;
|
||||||
const POPULAR_CACHE_PAGES = 3;
|
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 allSources: Source[] = $state([]);
|
||||||
let localSource: Source | null = $state(null);
|
let localSource: Source | null = $state(null);
|
||||||
let loadingSources = $state(false);
|
let loadingSources = $state(false);
|
||||||
|
|
||||||
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
let sourcesAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
sourcesAbort?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
sourcesAbort = ctrl;
|
|
||||||
loadingSources = true;
|
loadingSources = true;
|
||||||
getAdapter().getSources()
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((nodes: Source[]) => {
|
.then((d) => {
|
||||||
if (ctrl.signal.aborted) return;
|
const nodes = d.sources.nodes;
|
||||||
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
|
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
|
||||||
allSources = nodes.filter((s: Source) => s.id !== "0");
|
allSources = nodes.filter((src: Source) => src.id !== "0");
|
||||||
startSourceCacheBuild();
|
startSourceCacheBuild();
|
||||||
popularStart(allSources);
|
popularStart(allSources);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { if (!ctrl.signal.aborted) loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
return () => { ctrl.abort(); };
|
|
||||||
});
|
|
||||||
|
|
||||||
let popular_raw: Manga[] = $state([]);
|
let popular_raw: Manga[] = $state([]);
|
||||||
let popular_loading = $state(false);
|
let popular_loading = $state(false);
|
||||||
|
let popular_moreLoading = $state(false);
|
||||||
let popular_abortCtrl: AbortController | null = null;
|
let popular_abortCtrl: AbortController | null = null;
|
||||||
let popular_sourcePool: Source[] = [];
|
let popular_sourcePool: Source[] = $state([]);
|
||||||
let popular_sourceCursor = 0;
|
let popular_sourceCursor = $state(0);
|
||||||
|
let popular_hasMore = $state(false);
|
||||||
let popular_seenIds = new Set<number>();
|
let popular_seenIds = new Set<number>();
|
||||||
let popular_seenTitles = new Set<string>();
|
let popular_seenTitles = new Set<string>();
|
||||||
|
|
||||||
const popular_results = $derived(popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) })));
|
const popular_results: (Manga & { _priority: number })[] = $derived(
|
||||||
|
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
|
||||||
|
);
|
||||||
|
|
||||||
function popular_push(incoming: Manga[]) {
|
function popular_push(incoming: Manga[]) {
|
||||||
const toAdd: Manga[] = [];
|
const toAdd: Manga[] = [];
|
||||||
for (const m of incoming) {
|
for (const m of incoming) {
|
||||||
if (shouldHideNsfw(m as any, settingsState.settings)) continue;
|
if (shouldHideNsfw(m, store.settings)) continue;
|
||||||
if (popular_seenIds.has(m.id)) continue;
|
if (popular_seenIds.has(m.id)) continue;
|
||||||
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
||||||
if (popular_seenTitles.has(norm)) continue;
|
if (popular_seenTitles.has(norm)) continue;
|
||||||
@@ -112,19 +106,32 @@
|
|||||||
|
|
||||||
async function popular_fanOut(signal: AbortSignal) {
|
async function popular_fanOut(signal: AbortSignal) {
|
||||||
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
||||||
if (!batch.length) return;
|
if (!batch.length) { popular_hasMore = false; return; }
|
||||||
|
|
||||||
await runConcurrent(batch, async (src) => {
|
await runConcurrent(batch, async (src) => {
|
||||||
for (let p = 1; p <= SEARCH_PAGES; p++) {
|
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
try {
|
const key = `${src.id}|POPULAR|All:p${page}`;
|
||||||
const result = await getAdapter().browseSource(src.id, p);
|
let mangas: Manga[];
|
||||||
if (signal.aborted) return;
|
if (store.searchCache?.has(key)) {
|
||||||
popular_push(result.items as Manga[]);
|
mangas = store.searchCache.get(key)!;
|
||||||
if (!result.hasNextPage) break;
|
} else {
|
||||||
} catch { break; }
|
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);
|
}, signal);
|
||||||
|
|
||||||
popular_sourceCursor += batch.length;
|
popular_sourceCursor += batch.length;
|
||||||
|
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function popularStart(sources: Source[]) {
|
function popularStart(sources: Source[]) {
|
||||||
@@ -135,8 +142,10 @@
|
|||||||
popular_seenIds.clear();
|
popular_seenIds.clear();
|
||||||
popular_seenTitles.clear();
|
popular_seenTitles.clear();
|
||||||
popular_raw = [];
|
popular_raw = [];
|
||||||
popular_sourcePool = sources;
|
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
|
||||||
popular_sourceCursor = 0;
|
popular_sourceCursor = 0;
|
||||||
|
popular_hasMore = false;
|
||||||
|
popular_moreLoading = false;
|
||||||
popular_loading = true;
|
popular_loading = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -149,6 +158,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const sourceCache = new Map<number, CachedManga>();
|
export const sourceCache = new Map<number, CachedManga>();
|
||||||
|
|
||||||
let sourceCacheReady = $state(false);
|
let sourceCacheReady = $state(false);
|
||||||
let sourceCacheLoading = $state(false);
|
let sourceCacheLoading = $state(false);
|
||||||
let sourceCacheEnriching = $state(false);
|
let sourceCacheEnriching = $state(false);
|
||||||
@@ -159,12 +169,24 @@
|
|||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
||||||
}
|
}
|
||||||
await runConcurrent(tasks, async ({ src, page: p }) => {
|
await runConcurrent(tasks, async ({ src, page }) => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
try {
|
try {
|
||||||
const result = await getAdapter().browseSource(src.id, p);
|
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;
|
if (signal.aborted) return;
|
||||||
for (const m of result.items as Manga[]) {
|
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));
|
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -180,16 +202,19 @@
|
|||||||
await runConcurrent(unenriched, async (entry) => {
|
await runConcurrent(unenriched, async (entry) => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
try {
|
try {
|
||||||
const m = await getAdapter().getManga(String(entry.id));
|
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
|
||||||
|
FETCH_MANGA, { id: entry.id }, signal,
|
||||||
|
);
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
const updated = sourceCache.get(entry.id);
|
const updated = sourceCache.get(entry.id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updated.genre = (m as any).genre ?? [];
|
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||||
updated.status = (m as any).status ?? updated.status;
|
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||||
updated.lowerGenres = updated.genre.map((g: string) => g.toLowerCase());
|
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
|
||||||
updated.genreEnriched = true;
|
updated.genreEnriched = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError") return;
|
||||||
const updated = sourceCache.get(entry.id);
|
const updated = sourceCache.get(entry.id);
|
||||||
if (updated) updated.genreEnriched = true;
|
if (updated) updated.genreEnriched = true;
|
||||||
}
|
}
|
||||||
@@ -204,7 +229,8 @@
|
|||||||
sourceCacheAbort = ctrl;
|
sourceCacheAbort = ctrl;
|
||||||
sourceCacheLoading = true;
|
sourceCacheLoading = true;
|
||||||
sourceCache.clear();
|
sourceCache.clear();
|
||||||
buildSourceCache(allSources, ctrl.signal)
|
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||||
|
buildSourceCache(dedupedSources, ctrl.signal)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
sourceCacheReady = true;
|
sourceCacheReady = true;
|
||||||
@@ -218,13 +244,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
sourcesAbort?.abort();
|
|
||||||
popular_abortCtrl?.abort();
|
popular_abortCtrl?.abort();
|
||||||
sourceCacheAbort?.abort();
|
sourceCacheAbort?.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root anim-fade-in">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Search</span>
|
<span class="heading">Search</span>
|
||||||
|
|
||||||
@@ -232,19 +257,19 @@
|
|||||||
{#if anims && tabIndicator.width > 0}
|
{#if anims && tabIndicator.width > 0}
|
||||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="tab" class:tabActive={urlTab === "keyword"} onclick={() => setTab("keyword")}>
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Keyword
|
Keyword
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:tabActive={urlTab === "tag"} onclick={() => setTab("tag")}>
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Tags
|
Tags
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:tabActive={urlTab === "source"} onclick={() => setTab("source")}>
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -253,7 +278,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if urlTab === "keyword"}
|
{#if tab === "keyword"}
|
||||||
<KeywordTab
|
<KeywordTab
|
||||||
{allSources}
|
{allSources}
|
||||||
{availableLangs}
|
{availableLangs}
|
||||||
@@ -263,19 +288,19 @@
|
|||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
query={urlQuery}
|
query={store.searchQuery}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setSearchQuery}
|
||||||
onPrefillConsumed={() => { pendingPrefill = ""; }}
|
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
onPreview={setPreviewManga}
|
||||||
/>
|
/>
|
||||||
{:else if urlTab === "tag"}
|
{:else if tab === "tag"}
|
||||||
<TagTab
|
<TagTab
|
||||||
{allSources}
|
{allSources}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
{sourceCacheReady}
|
{sourceCacheReady}
|
||||||
{sourceCacheLoading}
|
{sourceCacheLoading}
|
||||||
{sourceCacheEnriching}
|
{sourceCacheEnriching}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
onPreview={setPreviewManga}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<SourceTab
|
<SourceTab
|
||||||
@@ -283,14 +308,13 @@
|
|||||||
{availableLangs}
|
{availableLangs}
|
||||||
{loadingSources}
|
{loadingSources}
|
||||||
{localSource}
|
{localSource}
|
||||||
{preselectedSourceId}
|
onPreview={setPreviewManga}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
||||||
@@ -300,5 +324,4 @@
|
|||||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
.tabActive:hover { color: var(--accent-fg); }
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
</style>
|
||||||
+30
-37
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { gql } from "@api/client";
|
||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
|
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||||
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||||
import type { Manga, Source } from "$lib/types";
|
import type { Manga, Source } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
@@ -14,13 +15,12 @@
|
|||||||
loadingSources: boolean;
|
loadingSources: boolean;
|
||||||
localSource: Source | null;
|
localSource: Source | null;
|
||||||
onPreview: (m: Manga) => void;
|
onPreview: (m: Manga) => void;
|
||||||
preselectedSourceId?: string;
|
|
||||||
}
|
}
|
||||||
let { allSources, availableLangs, loadingSources, localSource, onPreview, preselectedSourceId }: Props = $props();
|
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
|
||||||
|
|
||||||
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
let src_selectedLang = $state(settingsState.settings.preferredExtensionLang || "all");
|
let src_selectedLang = $state(preferredLang || "all");
|
||||||
let src_activeSource: Source | null = $state(null);
|
let src_activeSource: Source | null = $state(null);
|
||||||
let src_browseResults: Manga[] = $state([]);
|
let src_browseResults: Manga[] = $state([]);
|
||||||
let src_loadingBrowse = $state(false);
|
let src_loadingBrowse = $state(false);
|
||||||
@@ -34,19 +34,13 @@
|
|||||||
let ctx_y = $state(0);
|
let ctx_y = $state(0);
|
||||||
let ctx_source: Source | null = $state(null);
|
let ctx_source: Source | null = $state(null);
|
||||||
|
|
||||||
const pinnedIds = $derived(settingsState.settings.pinnedSourceIds ?? []);
|
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
|
||||||
const pinnedSources = $derived(
|
const pinnedSources = $derived(
|
||||||
pinnedIds
|
pinnedIds
|
||||||
.map((id: string) => allSources.find((s) => s.id === id))
|
.map(id => allSources.find(s => s.id === id))
|
||||||
.filter((s: Source | undefined): s is Source => !!s)
|
.filter((s): s is Source => !!s)
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!preselectedSourceId || !allSources.length || src_activeSource) return;
|
|
||||||
const target = allSources.find((s) => s.id === preselectedSourceId);
|
|
||||||
if (target) srcSelectSource(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!allSources.length) return;
|
if (!allSources.length) return;
|
||||||
const langs = new Set(allSources.map((s) => s.lang));
|
const langs = new Set(allSources.map((s) => s.lang));
|
||||||
@@ -56,7 +50,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const src_visibleSources = $derived.by(() => {
|
const src_visibleSources = $derived.by(() => {
|
||||||
const hide = (s: Source) => shouldHideSource(s, settingsState.settings);
|
const hide = (s: Source) => shouldHideSource(s, store.settings);
|
||||||
if (src_selectedLang !== "all") {
|
if (src_selectedLang !== "all") {
|
||||||
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||||
}
|
}
|
||||||
@@ -79,16 +73,15 @@
|
|||||||
src_abortCtrl = ctrl;
|
src_abortCtrl = ctrl;
|
||||||
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||||
try {
|
try {
|
||||||
let result: { items: Manga[]; hasNextPage: boolean };
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
if (type === "SEARCH" && q) {
|
FETCH_SOURCE_MANGA,
|
||||||
result = await getAdapter().searchSource(src.id, q, page, ctrl.signal);
|
{ source: src.id, type, page, query: q ?? null },
|
||||||
} else {
|
ctrl.signal,
|
||||||
result = await getAdapter().browseSource(src.id, page);
|
);
|
||||||
}
|
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const incoming = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
|
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||||
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||||
src_hasNextPage = result.hasNextPage;
|
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||||
src_currentPage = page;
|
src_currentPage = page;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
@@ -119,12 +112,6 @@
|
|||||||
}
|
}
|
||||||
function closeCtx() { ctx_source = null; }
|
function closeCtx() { ctx_source = null; }
|
||||||
|
|
||||||
function togglePinnedSource(id: string) {
|
|
||||||
const current = settingsState.settings.pinnedSourceIds ?? [];
|
|
||||||
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
|
|
||||||
updateSettings({ pinnedSourceIds: next });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => { src_abortCtrl?.abort(); });
|
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -208,6 +195,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="splitContent">
|
<div class="splitContent">
|
||||||
{#if !src_activeSource}
|
{#if !src_activeSource}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -261,7 +249,7 @@
|
|||||||
{#each src_browseResults as m, i (m.id)}
|
{#each src_browseResults as m, i (m.id)}
|
||||||
<button class="card" onclick={() => onPreview(m)}>
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="cardTitle">{m.title}</p>
|
<p class="cardTitle">{m.title}</p>
|
||||||
@@ -299,7 +287,7 @@
|
|||||||
{
|
{
|
||||||
label: isPinned ? "Unpin source" : "Pin source",
|
label: isPinned ? "Unpin source" : "Pin source",
|
||||||
icon: isPinned ? PushPinSlash : PushPin,
|
icon: isPinned ? PushPinSlash : PushPin,
|
||||||
onClick: () => { togglePinnedSource(ctx_source!.id); },
|
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
@@ -342,6 +330,7 @@
|
|||||||
.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); }
|
.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; }
|
.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); }
|
: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; }
|
.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 { 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); }
|
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||||
@@ -353,24 +342,28 @@
|
|||||||
.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 { 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:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
.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; }
|
.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); }
|
.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); }
|
.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; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
.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; }
|
.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; }
|
.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 { 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:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
@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; }
|
.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); }
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
.skTitle { height: 10px; width: 80%; }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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) } }
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
</style>
|
</style>
|
||||||
+84
-71
@@ -1,11 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, untrack } from "svelte";
|
import { onDestroy, untrack } from "svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { gql } from "@api/client";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||||
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "$lib/core/util";
|
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||||
import { runConcurrent, filterSourceCache, buildTagFilter, COMMON_GENRES, MANGA_STATUSES, type TagMode, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
import { runConcurrent } from "@core/async/batchRequests";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||||
import type { Manga, Source } from "$lib/types";
|
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 {
|
interface Props {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const SEARCH_LIMIT = 200;
|
const SEARCH_LIMIT = 200;
|
||||||
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||||
|
|
||||||
let tag_activeTags: string[] = $state([]);
|
let tag_activeTags: string[] = $state([]);
|
||||||
let tag_activeStatuses: string[] = $state([]);
|
let tag_activeStatuses: string[] = $state([]);
|
||||||
@@ -42,9 +53,6 @@
|
|||||||
let tag_localOffset = $state(0);
|
let tag_localOffset = $state(0);
|
||||||
let tag_localHasNext = $state(false);
|
let tag_localHasNext = $state(false);
|
||||||
let tag_abortLocal: AbortController | null = null;
|
let tag_abortLocal: AbortController | null = null;
|
||||||
let tag_abortLoadMore: AbortController | null = null;
|
|
||||||
|
|
||||||
const renderLimit = $derived(settingsState.settings.renderLimit ?? 48);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _tags = tag_activeTags;
|
const _tags = tag_activeTags;
|
||||||
@@ -53,6 +61,9 @@
|
|||||||
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
|
||||||
|
});
|
||||||
|
|
||||||
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
||||||
if (activeTags.length === 0 && activeStatuses.length === 0) {
|
if (activeTags.length === 0 && activeStatuses.length === 0) {
|
||||||
@@ -60,46 +71,45 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tag_abortLocal?.abort();
|
tag_abortLocal?.abort();
|
||||||
tag_abortLoadMore?.abort();
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
tag_abortLocal = ctrl;
|
tag_abortLocal = ctrl;
|
||||||
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||||
tag_loadingLocal = true;
|
tag_loadingLocal = true;
|
||||||
const limit = renderLimit;
|
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||||
try {
|
MANGAS_BY_GENRE,
|
||||||
const d = await getAdapter().getMangasByGenre(
|
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
|
||||||
buildTagFilter(activeTags, tagMode, activeStatuses), limit, 0, ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
).then((d) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
tag_localResults = d.items.filter(nsfwFilter);
|
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
|
||||||
tag_totalCount = d.totalCount;
|
tag_totalCount = d.mangas.totalCount;
|
||||||
tag_localHasNext = d.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset = limit;
|
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||||
if (d.hasNextPage && tag_localResults.length < 20) tagLoadMoreLocal();
|
}).catch((e: any) => {
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
}).finally(() => {
|
||||||
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tagLoadMoreLocal() {
|
async function tagLoadMoreLocal() {
|
||||||
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||||
tag_abortLoadMore?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
tag_abortLoadMore = ctrl;
|
|
||||||
tag_loadingMoreLocal = true;
|
tag_loadingMoreLocal = true;
|
||||||
const limit = renderLimit;
|
tag_abortLocal?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
tag_abortLocal = ctrl;
|
||||||
try {
|
try {
|
||||||
const d = await getAdapter().getMangasByGenre(
|
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||||
buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), limit, tag_localOffset, ctrl.signal,
|
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;
|
if (ctrl.signal.aborted) return;
|
||||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
|
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||||
tag_localResults = [...tag_localResults, ...d.items.filter(nsfwFilter)];
|
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
|
||||||
tag_localHasNext = d.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset += limit;
|
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -109,6 +119,7 @@
|
|||||||
|
|
||||||
let tag_searchSources = $state(false);
|
let tag_searchSources = $state(false);
|
||||||
let tag_sourceFiltered: CachedManga[] = $state([]);
|
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||||
|
|
||||||
let tag_sourceFanOut: Manga[] = $state([]);
|
let tag_sourceFanOut: Manga[] = $state([]);
|
||||||
let tag_fanOutLoading = $state(false);
|
let tag_fanOutLoading = $state(false);
|
||||||
let tag_fanOutAbort: AbortController | null = null;
|
let tag_fanOutAbort: AbortController | null = null;
|
||||||
@@ -121,7 +132,7 @@
|
|||||||
const _search = tag_searchSources;
|
const _search = tag_searchSources;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
||||||
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, settingsState.settings);
|
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
|
||||||
} else {
|
} else {
|
||||||
tag_sourceFiltered = [];
|
tag_sourceFiltered = [];
|
||||||
}
|
}
|
||||||
@@ -153,22 +164,33 @@
|
|||||||
const seenIds = new Set<number>();
|
const seenIds = new Set<number>();
|
||||||
const seenTitles = new Set<string>();
|
const seenTitles = new Set<string>();
|
||||||
const genreLower = genre.toLowerCase();
|
const genreLower = genre.toLowerCase();
|
||||||
|
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||||
const srcs = allSources.filter((s) => !shouldHideNsfw(s as any, settingsState.settings));
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async (src) => {
|
await runConcurrent(srcs, async (src) => {
|
||||||
for (let page = 1; page <= 2; page++) {
|
for (let page = 1; page <= 2; page++) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
|
||||||
try {
|
let mangas: Manga[];
|
||||||
result = await getAdapter().searchSource(src.id, genre, page, ctrl.signal);
|
let hasNextPage = false;
|
||||||
} catch { return; }
|
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;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
const matching = result.items.filter((m) =>
|
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)
|
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
|
||||||
);
|
);
|
||||||
const candidates = (matching.length ? matching : result.items).filter(
|
const candidates = (matching.length ? matching : mangas).filter(
|
||||||
(m) => !shouldHideNsfw(m as any, settingsState.settings)
|
(m) => !shouldHideNsfw(m, store.settings)
|
||||||
);
|
);
|
||||||
const toAdd: Manga[] = [];
|
const toAdd: Manga[] = [];
|
||||||
for (const m of candidates) {
|
for (const m of candidates) {
|
||||||
@@ -182,7 +204,7 @@
|
|||||||
if (toAdd.length) {
|
if (toAdd.length) {
|
||||||
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||||
}
|
}
|
||||||
if (!result.hasNextPage) return;
|
if (!hasNextPage) return;
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
|
|
||||||
@@ -191,22 +213,13 @@
|
|||||||
|
|
||||||
let tag_autoSearchFired = $state(false);
|
let tag_autoSearchFired = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void tag_activeTags;
|
const _tags = tag_activeTags;
|
||||||
void tag_activeStatuses;
|
const _statuses = tag_activeStatuses;
|
||||||
untrack(() => { tag_autoSearchFired = false; });
|
untrack(() => { tag_autoSearchFired = false; });
|
||||||
});
|
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
||||||
$effect(() => {
|
if (tag_localResults.length < 20) {
|
||||||
const _loadingLocal = tag_loadingLocal;
|
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
||||||
const _hasFilters = tag_hasActiveFilters;
|
|
||||||
const _resultLen = tag_localResults.length;
|
|
||||||
const _cacheReady = sourceCacheReady;
|
|
||||||
if (!_loadingLocal && _hasFilters && _cacheReady) {
|
|
||||||
untrack(() => {
|
|
||||||
if (!tag_autoSearchFired && !tag_searchSources && _resultLen < 20) {
|
|
||||||
tag_autoSearchFired = true;
|
|
||||||
tag_searchSources = true;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +232,7 @@
|
|||||||
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
||||||
return dedupeMangaByTitle(
|
return dedupeMangaByTitle(
|
||||||
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
||||||
settingsState.settings.mangaLinks,
|
store.settings.mangaLinks,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,7 +252,6 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
tag_abortLocal?.abort();
|
tag_abortLocal?.abort();
|
||||||
tag_abortLoadMore?.abort();
|
|
||||||
tag_fanOutAbort?.abort();
|
tag_fanOutAbort?.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -277,6 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="splitContent">
|
<div class="splitContent">
|
||||||
{#if !tag_hasActiveFilters}
|
{#if !tag_hasActiveFilters}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -332,6 +345,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="splitContentHeader">
|
<div class="splitContentHeader">
|
||||||
<span class="splitContentTitle">
|
<span class="splitContentTitle">
|
||||||
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
|
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
|
||||||
@@ -359,6 +373,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{#if tag_loadingLocal}
|
{#if tag_loadingLocal}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each Array(48) as _, i (i)}
|
{#each Array(48) as _, i (i)}
|
||||||
@@ -370,7 +385,7 @@
|
|||||||
{#each tag_mergedResults as m, i (m.id)}
|
{#each tag_mergedResults as m, i (m.id)}
|
||||||
<button class="card" onclick={() => onPreview(m)}>
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="cardTitle">{m.title}</p>
|
<p class="cardTitle">{m.title}</p>
|
||||||
@@ -380,10 +395,6 @@
|
|||||||
{#each Array(12) as _, i (i)}
|
{#each Array(12) as _, i (i)}
|
||||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if tag_localHasNext}
|
|
||||||
<div class="loadMoreRow">
|
|
||||||
<button class="loadMoreBtn" onclick={tagLoadMoreLocal}>Load more</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -423,6 +434,7 @@
|
|||||||
.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); }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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); }
|
.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); }
|
||||||
@@ -439,23 +451,24 @@
|
|||||||
.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 { 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)); }
|
.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; }
|
.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; }
|
.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; }
|
||||||
.loadMoreRow { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
|
||||||
.loadMoreBtn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 20px; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
|
||||||
.loadMoreBtn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
.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); }
|
.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; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
.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; }
|
.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); }
|
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
@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; }
|
.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); }
|
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||||
.skTitle { height: 10px; width: 80%; }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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) } }
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Search } from "./components/Search.svelte";
|
||||||
|
export * from "./lib/searchFilter";
|
||||||
+9
-8
@@ -1,7 +1,5 @@
|
|||||||
import type { Settings } from "$lib/types/settings";
|
import type { Settings } from "@types";
|
||||||
import { shouldHideNsfw } from "$lib/core/util";
|
import { shouldHideNsfw } from "@core/util";
|
||||||
|
|
||||||
export { shouldHideNsfw };
|
|
||||||
|
|
||||||
export const PAGE_SIZE = 50;
|
export const PAGE_SIZE = 50;
|
||||||
export const INITIAL_PAGES = 3;
|
export const INITIAL_PAGES = 3;
|
||||||
@@ -100,14 +98,17 @@ export function filterSourceCache(
|
|||||||
return [...sourceCache.values()].filter((m) => {
|
return [...sourceCache.values()].filter((m) => {
|
||||||
if (shouldHideNsfw(m as any, settings)) return false;
|
if (shouldHideNsfw(m as any, settings)) return false;
|
||||||
|
|
||||||
const statusMatch = statuses.length === 0 || statuses.includes(m.status);
|
const statusMatch =
|
||||||
|
statuses.length === 0 || statuses.includes(m.status);
|
||||||
|
|
||||||
let genreMatch = true;
|
let genreMatch = true;
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
const lower = m.lowerGenres;
|
const lower = m.lowerGenres;
|
||||||
genreMatch = mode === "AND"
|
if (mode === "AND") {
|
||||||
? tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())))
|
genreMatch = tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
||||||
: tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
} else {
|
||||||
|
genreMatch = tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return statusMatch && genreMatch;
|
return statusMatch && genreMatch;
|
||||||
+23
-15
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotchIcon, ArrowClockwiseIcon, XIcon } from "phosphor-svelte";
|
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { longPress } from "$lib/core/ui/touchscreen";
|
import { longPress } from "@core/ui/touchscreen";
|
||||||
import type { DownloadQueueItem } from "$lib/types/api";
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
import { pageProgress } from "$lib/components/downloads/lib/downloadQueue";
|
import { pageProgress } from "../lib/downloadQueue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: DownloadQueueItem;
|
item: DownloadQueueItem;
|
||||||
@@ -78,12 +78,12 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if isError}
|
{#if isError}
|
||||||
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
|
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
|
||||||
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwiseIcon size={11} weight="bold" />{/if}
|
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isActive}
|
{#if !isActive}
|
||||||
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
|
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
|
||||||
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<XIcon size={12} weight="light" />{/if}
|
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,35 +92,43 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.row {
|
.row {
|
||||||
display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3);
|
display: flex;
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md);
|
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);
|
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;
|
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:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
.row.row-active { background: color-mix(in srgb, var(--accent) 6%, var(--bg-raised)); border-color: var(--accent-dim); }
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
.row.row-selected { background: color-mix(in srgb, var(--accent) 8%, transparent); border-color: var(--accent-dim); }
|
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
.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); }
|
.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; }
|
: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; }
|
.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; }
|
.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; }
|
.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-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-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.35; }
|
.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; }
|
.row-active .progress-bar { opacity: 1; }
|
||||||
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
.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; }
|
.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-active .pages-label { color: var(--accent-fg); opacity: 0.8; }
|
|
||||||
|
|
||||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
.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 { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.row-active .state-label { color: var(--accent-fg); opacity: 0.8; }
|
|
||||||
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||||
|
|
||||||
.actions { display: flex; align-items: center; gap: 2px; }
|
.actions { display: flex; align-items: center; gap: 2px; }
|
||||||
+16
-8
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotchIcon } from "phosphor-svelte";
|
import { CircleNotch } from "phosphor-svelte";
|
||||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
import DownloadItem from "./DownloadItem.svelte";
|
||||||
import type { DownloadQueueItem } from "$lib/types/api";
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queue: DownloadQueueItem[];
|
queue: DownloadQueueItem[];
|
||||||
@@ -11,12 +11,14 @@
|
|||||||
selected: Set<number>;
|
selected: Set<number>;
|
||||||
onRemove: (chapterId: number) => void;
|
onRemove: (chapterId: number) => void;
|
||||||
onRetry: (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;
|
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
queue, loading, isRunning, dequeueing, selected,
|
queue, loading, isRunning, dequeueing, selected,
|
||||||
onRemove, onRetry, onSelect,
|
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
{#each Array(5) as _, i (i)}
|
{#each Array(5) as _, i (i)}
|
||||||
<div class="sk-row">
|
<div class="sk-row">
|
||||||
<div class="sk-thumb skeleton"></div>
|
<div class="sk-thumb skeleton"></div>
|
||||||
|
|
||||||
<div class="sk-info">
|
<div class="sk-info">
|
||||||
<div class="skeleton sk-title"></div>
|
<div class="skeleton sk-title"></div>
|
||||||
<div class="skeleton sk-chapter"></div>
|
<div class="skeleton sk-chapter"></div>
|
||||||
@@ -33,9 +36,12 @@
|
|||||||
<div class="skeleton sk-pages"></div>
|
<div class="skeleton sk-pages"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sk-right">
|
<div class="sk-right">
|
||||||
<div class="skeleton sk-state"></div>
|
<div class="skeleton sk-state"></div>
|
||||||
<div class="sk-actions"><div class="skeleton sk-btn"></div></div>
|
<div class="sk-actions">
|
||||||
|
<div class="skeleton sk-btn"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -52,6 +58,8 @@
|
|||||||
isSelected={selected.has(item.chapter.id)}
|
isSelected={selected.has(item.chapter.id)}
|
||||||
{onRemove}
|
{onRemove}
|
||||||
{onRetry}
|
{onRetry}
|
||||||
|
{onReorder}
|
||||||
|
{onReorderEdge}
|
||||||
{onSelect}
|
{onSelect}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -67,9 +75,9 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
color-mix(in srgb, var(--bg-overlay) 90%, var(--text-primary) 6%) 20%,
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||||
color-mix(in srgb, var(--bg-overlay) 76%, var(--text-primary) 16%) 50%,
|
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||||
color-mix(in srgb, var(--bg-overlay) 90%, var(--text-primary) 6%) 80%
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||||
);
|
);
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
animation: shimmer 1.45s ease-in-out infinite;
|
animation: shimmer 1.45s ease-in-out infinite;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user