mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6ea1fab67 | |||
| bf19ee02bc | |||
| 09d794da96 | |||
| baece20f46 | |||
| b170a151f0 | |||
| 6a84280db0 | |||
| be38d87bec | |||
| ab9305e6ab | |||
| ceb9ba12d7 | |||
| 2fa33bc928 | |||
| 5c703bdba5 | |||
| a041b182e5 | |||
| 9dad1fb329 | |||
| 31a19687ce | |||
| 437b52fd8b | |||
| 1e159bbd73 | |||
| cb3d8d64fa | |||
| c0a95ff899 | |||
| ddaca9d126 | |||
| 77b28e97a4 | |||
| f10b343108 | |||
| a8ad9034fc | |||
| f99fa60e8e | |||
| 915ff66b2f | |||
| abd60f261f | |||
| fc20835dde | |||
| 04631d93ef | |||
| 5c09cd15ad | |||
| 7af69fd77c | |||
| 0b6372bd17 | |||
| 32bdeb92ff | |||
| 22c4a222d8 | |||
| 26cb16ec0f | |||
| 8c2917b698 | |||
| 6d33fb7ae1 | |||
| 0e7ff1a27c | |||
| 685bd9b9da | |||
| 3926b5d064 | |||
| 9f6996dcdb | |||
| 294865fe9d | |||
| 13e760594d | |||
| b44b12ba86 | |||
| 3b8c8dea38 | |||
| 615fa1e92f | |||
| 248b046627 | |||
| 79e5548879 | |||
| ed4c11ca7e | |||
| 5dfbc80bbe | |||
| 8aa92e6b54 | |||
| b55dd16d0d | |||
| 7c6aeb8f4c |
@@ -0,0 +1,78 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 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
|
||||||
|
#
|
||||||
|
# Uses only POSIX -E grep (no -P) so this works on both GNU grep (Linux/Windows)
|
||||||
|
# and BSD grep (macOS), which does not support -P/PCRE.
|
||||||
|
|
||||||
|
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
||||||
|
_t=$(cat "$_nix")
|
||||||
|
|
||||||
|
# Match `key = "value"` with -E, then strip the surrounding quotes.
|
||||||
|
_pick() { echo "$_t" | grep -oE "${1}"'[[:space:]]*=[[:space:]]*"[^"]+"' | grep -oE '"[^"]+"' | tr -d '"'; }
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with: { version: 10 }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Build frontend and pack tarball
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:static
|
||||||
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
|
|
||||||
|
- name: Compute frontend-dist sha256
|
||||||
|
run: |
|
||||||
|
SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||||
|
echo "FRONTEND_SHA=$SHA" >> $GITHUB_ENV
|
||||||
|
echo "frontend-dist.tar.gz sha256: $SHA"
|
||||||
|
|
||||||
|
- name: Patch frontend-dist sha256 in flatpak manifest
|
||||||
|
run: |
|
||||||
|
python3 -c "
|
||||||
|
import re, pathlib, os
|
||||||
|
p = pathlib.Path('io.github.moku_project.Moku.yml')
|
||||||
|
content = p.read_text()
|
||||||
|
# Replace the sha256 line that follows the frontend-dist.tar.gz source entry
|
||||||
|
content = re.sub(
|
||||||
|
r'(path: packaging/frontend-dist\.tar\.gz\n\s+sha256: )[0-9a-f]{64}',
|
||||||
|
r'\g<1>' + os.environ['FRONTEND_SHA'],
|
||||||
|
content
|
||||||
|
)
|
||||||
|
p.write_text(content)
|
||||||
|
"
|
||||||
|
|
||||||
|
- 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 and SDK
|
||||||
|
run: |
|
||||||
|
flatpak --user install -y --noninteractive flathub \
|
||||||
|
org.gnome.Platform//48 \
|
||||||
|
org.gnome.Sdk//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: |
|
||||||
|
# Poll for up to 10 minutes — the release is created by the Windows workflow
|
||||||
|
# which may still be building when the flatpak bundle finishes.
|
||||||
|
for i in $(seq 1 40); 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/40"; sleep 15
|
||||||
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found after polling"; 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,24 +16,15 @@ 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:
|
with: { version: latest }
|
||||||
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 dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
|
||||||
- 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/
|
||||||
@@ -43,77 +34,56 @@ 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
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist-linux, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist-linux
|
- name: Read versions
|
||||||
path: dist/
|
run: |
|
||||||
|
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 \
|
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||||
libappindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
patchelf \
|
|
||||||
libfuse2
|
|
||||||
|
|
||||||
- name: Install Rust
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
with: { targets: x86_64-unknown-linux-gnu }
|
||||||
with:
|
|
||||||
targets: x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: Swatinem/rust-cache@v2
|
||||||
uses: Swatinem/rust-cache@v2
|
with: { workspaces: src-tauri }
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: latest }
|
||||||
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/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-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 \
|
||||||
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
suwayomi-extracted/jre/bin/java \
|
||||||
JAVA="suwayomi-extracted/jre/bin/java"
|
suwayomi-extracted/bin/catch_abort.so; do
|
||||||
CATCH="suwayomi-extracted/bin/catch_abort.so"
|
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -129,43 +99,30 @@ 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:
|
env: { NO_STRIP: "true" }
|
||||||
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'"$VERSION"'") | .id' | head -1)
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
[ -n "$RELEASE_ID" ] && break
|
||||||
echo "Waiting for release to exist... attempt $i"
|
echo "Waiting for release... attempt $i"; sleep 15
|
||||||
sleep 15
|
|
||||||
done
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
upload() {
|
||||||
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 @"$file" \
|
--data-binary @"$1" \
|
||||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||||
}
|
}
|
||||||
|
|
||||||
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 "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
|
||||||
[ -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.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -16,28 +16,16 @@ 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:
|
with: { version: 10 }
|
||||||
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 dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
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)
|
||||||
@@ -46,149 +34,109 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
run: |
|
||||||
with:
|
source .github/read_versions.sh
|
||||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
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
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
with:
|
||||||
workspaces: src-tauri
|
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
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: |
|
||||||
download_suwayomi() {
|
dl() {
|
||||||
local asset="$1" sha="$2" outdir="$3"
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${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
|
||||||
download_suwayomi \
|
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
||||||
"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() {
|
||||||
stage_arch() {
|
local srcdir="$1" arch="$2"
|
||||||
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; }
|
||||||
if [ -z "$JAR" ]; then
|
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
find "$srcdir" -type f | head -30
|
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
exit 1
|
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
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_arch suwayomi-arm64 aarch64-apple-darwin
|
stage suwayomi-x64 x86_64-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
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
- name: Swap bundle for aarch64
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
run: |
|
c = json.loads(p.read_text())
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
p.write_text(json.dumps(c, indent=2))
|
||||||
src-tauri/binaries/suwayomi-bundle
|
"
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||||
|
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: Swap bundle for x86_64
|
- name: Build Tauri app (x86_64)
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||||
src-tauri/binaries/suwayomi-bundle
|
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
|
||||||
- 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" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
echo "Waiting for release to exist... attempt $i"
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||||
sleep 15
|
[ -n "$RELEASE_ID" ] && break
|
||||||
|
echo "Waiting for release... attempt $i"; sleep 15
|
||||||
done
|
done
|
||||||
|
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
upload() {
|
||||||
echo "ERROR: Could not find release for v$VERSION after waiting"
|
curl -s -X POST \
|
||||||
exit 1
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
fi
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$1" \
|
||||||
echo "Found release ID: $RELEASE_ID"
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||||
|
|
||||||
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_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
ARM64=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
X64=$(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 "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
[ -n "$X64" ] && upload "$X64" "moku-macos-x64-${{ github.event.inputs.version }}.dmg"
|
||||||
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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: 10 }
|
||||||
|
- 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,120 +16,87 @@ 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:
|
with: { version: 10 }
|
||||||
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 dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
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
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist-windows, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist-windows
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
targets: x86_64-pc-windows-msvc
|
source .github/read_versions.sh
|
||||||
|
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
with: { targets: x86_64-pc-windows-msvc }
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: 10 }
|
||||||
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/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
- name: Stage 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
|
||||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
||||||
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
|
||||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
||||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
if [ -z "$JAVA" ]; then
|
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
||||||
echo "ERROR: jre/bin/java.exe not found"
|
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
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
|
python3 -c "
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path('src-tauri/tauri.conf.json')
|
||||||
|
c = json.loads(p.read_text())
|
||||||
|
c.setdefault('build', {})['beforeBuildCommand'] = ''
|
||||||
|
p.write_text(json.dumps(c, indent=2))
|
||||||
|
"
|
||||||
|
|
||||||
- name: Delete existing draft release if present
|
- name: Delete existing draft release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -138,14 +105,10 @@ 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
|
||||||
@@ -158,10 +121,10 @@ jobs:
|
|||||||
releaseBody: |
|
releaseBody: |
|
||||||
Moku v${{ github.event.inputs.version }}
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
**Windows:** `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
**macOS arm64:** `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
**macOS x64:** `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
**Linux:** Download `moku.flatpak`
|
**Linux:** `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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.9.4
|
pkgver=0.10.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -25,7 +25,7 @@ source=(
|
|||||||
)
|
)
|
||||||
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
sha256sums=(
|
sha256sums=(
|
||||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
'589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275'
|
||||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
b2sums=(
|
b2sums=(
|
||||||
|
|||||||
@@ -1,42 +1,173 @@
|
|||||||
# sv
|
<div align="center">
|
||||||
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
|
</div>
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
<div align="center">
|
||||||
|
|
||||||
## Creating a project
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
|

|
||||||
|
[](https://github.com/moku-project/Moku)
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
</div>
|
||||||
|
|
||||||
```sh
|
<br/>
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
```
|
```
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
```sh
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
# recreate this project
|
|
||||||
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install io.github.moku_app.Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
```bash
|
||||||
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
### Nix
|
||||||
|
|
||||||
To create a production version of your app:
|
```bash
|
||||||
|
nix run github:moku-project/Moku
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
Add to your flake:
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
```nix
|
||||||
|
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.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#Requires -Version 7
|
||||||
|
param(
|
||||||
|
[switch]$SkipFrontend,
|
||||||
|
[switch]$SkipSuwayomi
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Need($cmd) {
|
||||||
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "Required tool not found: $cmd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
Step "Reading nix/versions.nix"
|
||||||
|
$nix = Get-Content "$Root\nix\versions.nix" -Raw
|
||||||
|
$MOKU_VERSION = if ($nix -match 'moku\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "moku version not found" }
|
||||||
|
$SUWA_VERSION = if ($nix -match 'version\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "suwayomi version not found" }
|
||||||
|
$SUWA_HASH = if ($nix -match 'windowsHash\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "windowsHash not found" }
|
||||||
|
Write-Host " moku=$MOKU_VERSION suwayomi=$SUWA_VERSION"
|
||||||
|
|
||||||
|
Need "pnpm"; Need "cargo"; Need "node"
|
||||||
|
|
||||||
|
if (-not $SkipFrontend) {
|
||||||
|
Step "pnpm install"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "pnpm install failed" }
|
||||||
|
|
||||||
|
Step "Frontend build"
|
||||||
|
$env:MOKU_TARGET = "static"
|
||||||
|
pnpm build:static
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "Frontend build failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$BundleDir = "$Root\src-tauri\binaries\suwayomi-bundle"
|
||||||
|
$ZipPath = "$env:TEMP\suwayomi-windows-$SUWA_VERSION.zip"
|
||||||
|
$ExtractDir = "$env:TEMP\suwayomi-extracted-$SUWA_VERSION"
|
||||||
|
|
||||||
|
if (-not $SkipSuwayomi) {
|
||||||
|
Step "Downloading Suwayomi v$SUWA_VERSION"
|
||||||
|
$ZipUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ZipPath)) {
|
||||||
|
Invoke-WebRequest -Uri $ZipUrl -OutFile $ZipPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = (Get-FileHash $ZipPath -Algorithm SHA256).Hash.ToLower()
|
||||||
|
if ($actual -ne $SUWA_HASH.ToLower()) {
|
||||||
|
Write-Error "Hash mismatch`n expected: $SUWA_HASH`n got: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Step "Staging bundle"
|
||||||
|
if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force }
|
||||||
|
Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir
|
||||||
|
|
||||||
|
$topDirs = @(Get-ChildItem $ExtractDir -Directory)
|
||||||
|
$topFiles = @(Get-ChildItem $ExtractDir -File)
|
||||||
|
$SrcDir = if ($topDirs.Count -eq 1 -and $topFiles.Count -eq 0) { $topDirs[0].FullName } else { $ExtractDir }
|
||||||
|
|
||||||
|
if (Test-Path $BundleDir) { Remove-Item $BundleDir -Recurse -Force }
|
||||||
|
Copy-Item $SrcDir $BundleDir -Recurse
|
||||||
|
|
||||||
|
$java = Get-ChildItem $BundleDir -Recurse -Filter "java.exe" | Where-Object { $_.FullName -match "jre.bin" } | Select-Object -First 1
|
||||||
|
$jar = Get-ChildItem $BundleDir -Recurse -Filter "Suwayomi-Server.jar" | Select-Object -First 1
|
||||||
|
if (-not $java) { Write-Error "java.exe not found in staged bundle" }
|
||||||
|
if (-not $jar) { Write-Error "Suwayomi-Server.jar not found in staged bundle" }
|
||||||
|
Write-Host " java: $($java.FullName)"
|
||||||
|
Write-Host " jar: $($jar.FullName)"
|
||||||
|
} elseif (-not (Test-Path $BundleDir)) {
|
||||||
|
Write-Error "Bundle dir missing at $BundleDir — run without -SkipSuwayomi first"
|
||||||
|
}
|
||||||
|
|
||||||
|
Step "Patching tauri.conf.json"
|
||||||
|
$tauriConf = "$Root\src-tauri\tauri.conf.json"
|
||||||
|
$original = Get-Content $tauriConf -Raw
|
||||||
|
Set-Content $tauriConf ($original -replace '"beforeBuildCommand":\s*"pnpm build"', '"beforeBuildCommand": ""') -NoNewline
|
||||||
|
|
||||||
|
Step "Tauri build"
|
||||||
|
$env:TAURI_SKIP_DEVSERVER_CHECK = "true"
|
||||||
|
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
|
$buildExit = $LASTEXITCODE
|
||||||
|
|
||||||
|
Set-Content $tauriConf $original -NoNewline
|
||||||
|
|
||||||
|
if ($buildExit -ne 0) { Write-Error "Tauri build failed (exit $buildExit)" }
|
||||||
|
|
||||||
|
Step "Artifacts"
|
||||||
|
$out = "$Root\src-tauri\target\x86_64-pc-windows-msvc\release\bundle"
|
||||||
|
$msi = Get-ChildItem "$out\msi" -Filter "*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
$exe = Get-ChildItem "$out\nsis" -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
Write-Host "`nDone — Moku $MOKU_VERSION" -ForegroundColor Green
|
||||||
|
if ($msi) { Write-Host " MSI: $($msi.FullName)" -ForegroundColor Yellow }
|
||||||
|
if ($exe) { Write-Host " EXE: $($exe.FullName)" -ForegroundColor Yellow }
|
||||||
|
if (-not $msi -and -not $exe) { Write-Host " No artifacts found in $out" -ForegroundColor Red }
|
||||||
Generated
+12
-12
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1778716662,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773821835,
|
"lastModified": 1780243769,
|
||||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -36,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772328832,
|
"lastModified": 1777168982,
|
||||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -63,11 +63,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773975983,
|
"lastModified": 1780543271,
|
||||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ modules:
|
|||||||
|
|
||||||
- name: libayatana-indicator
|
- name: libayatana-indicator
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
config-opts:
|
config-opts:
|
||||||
- -DENABLE_TESTS=OFF
|
- -DENABLE_TESTS=OFF
|
||||||
- -DGSETTINGS_COMPILE=OFF
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
@@ -90,6 +93,9 @@ modules:
|
|||||||
|
|
||||||
- name: libayatana-appindicator
|
- name: libayatana-appindicator
|
||||||
buildsystem: cmake-ninja
|
buildsystem: cmake-ninja
|
||||||
|
build-options:
|
||||||
|
env:
|
||||||
|
PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig
|
||||||
config-opts:
|
config-opts:
|
||||||
- -DENABLE_TESTS=OFF
|
- -DENABLE_TESTS=OFF
|
||||||
- -DENABLE_BINDINGS_MONO=OFF
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
@@ -220,8 +226,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
|
||||||
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -232,6 +238,7 @@ modules:
|
|||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
|
TAURI_CONFIG: '{"build":{"devUrl":null,"frontendDist":"../dist"},"app":{"windows":[{"devtools":false}]},"bundle":{"externalBin":[]}}'
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
@@ -244,11 +251,10 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/moku-project/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.9.4
|
commit: baece20f467d2c7d4cebaa9ea8892980aa93aa10
|
||||||
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src, versions }:
|
|
||||||
|
|
||||||
stdenv.mkDerivation {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version src;
|
|
||||||
|
|
||||||
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
|
||||||
|
|
||||||
pnpmDeps = fetchPnpmDeps {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version src;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = versions.frontend.pnpmHash;
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$(mktemp -d)
|
|
||||||
pnpm build:static
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
}
|
|
||||||
+1
-1
@@ -29,7 +29,7 @@ pkgs.stdenv.mkDerivation {
|
|||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
pname = "moku";
|
pname = "moku";
|
||||||
inherit version src;
|
inherit version src;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 3;
|
||||||
hash = versions.frontend.pnpmHash;
|
hash = versions.frontend.pnpmHash;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+25
-8
@@ -7,6 +7,7 @@
|
|||||||
gnused
|
gnused
|
||||||
coreutils
|
coreutils
|
||||||
git
|
git
|
||||||
|
xxd
|
||||||
rustToolchain
|
rustToolchain
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
@@ -85,17 +86,31 @@ PYEOF
|
|||||||
|
|
||||||
if [[ $# -ge 2 ]]; then
|
if [[ $# -ge 2 ]]; then
|
||||||
SUWA_VER="$2"
|
SUWA_VER="$2"
|
||||||
JAR_URL="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}/Suwayomi-Server-v${SUWA_VER}.jar"
|
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v''${SUWA_VER}"
|
||||||
|
|
||||||
SUWA_SHA_HEX=$(curl -fsSL "$JAR_URL" | sha256sum | awk '{print $1}')
|
echo "Fetching Suwayomi v''${SUWA_VER} hashes (5 downloads)..."
|
||||||
SUWA_SHA_SRI=$(echo "$SUWA_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
|
||||||
|
|
||||||
sed -i "s/version = \"[^\"]*\"/version = \"$SUWA_VER\"/" "$VERSIONS"
|
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
|
||||||
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"$SUWA_SHA_SRI\"|" "$VERSIONS"
|
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
|
||||||
|
|
||||||
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
|
JAR_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}.jar")
|
||||||
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
|
WIN_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-windows-x64.zip")
|
||||||
python3 - "$MANIFEST" "$SUWA_SHA_HEX" <<'PYEOF'
|
LINUX_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-linux-x64.tar.gz")
|
||||||
|
ARM64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-arm64.tar.gz")
|
||||||
|
X64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-x64.tar.gz")
|
||||||
|
|
||||||
|
JAR_SRI=$(to_sri "$JAR_SHA")
|
||||||
|
|
||||||
|
sed -i "s/version = \"[^\"]*\"/version = \"''${SUWA_VER}\"/" "$VERSIONS"
|
||||||
|
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"''${JAR_SRI}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"''${WIN_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"''${LINUX_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"''${ARM64_SHA}\"|" "$VERSIONS"
|
||||||
|
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"''${X64_SHA}\"|" "$VERSIONS"
|
||||||
|
|
||||||
|
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v''${SUWA_VER}/|" "$MANIFEST"
|
||||||
|
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v''${SUWA_VER}.jar|g" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$JAR_SHA" <<'PYEOF'
|
||||||
import re, sys
|
import re, sys
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
text = open(path).read()
|
text = open(path).read()
|
||||||
@@ -106,6 +121,8 @@ if n == 0:
|
|||||||
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
|
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
|
||||||
open(path, 'w').write(updated)
|
open(path, 'w').write(updated)
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
|
echo "Suwayomi hashes written."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||||
|
|||||||
+10
-6
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
moku = "0.9.4";
|
moku = "0.10.0";
|
||||||
|
|
||||||
suwayomi = {
|
suwayomi = {
|
||||||
version = "2.1.2087";
|
version = "2.2.2196";
|
||||||
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
|
||||||
|
windowsHash = "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d";
|
||||||
|
linuxHash = "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f";
|
||||||
|
macosArm64Hash = "9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43";
|
||||||
|
macosX64Hash = "eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = {
|
frontend = {
|
||||||
pnpmHash = "sha256-8bkwONUrr+U2OXYXvcsGytKhcImnehu+2bI/hmoFjJ4=";
|
pnpmHash = "sha256-fBkNpQXEeGZNbrpx7+0xVYYtQ6dGvpgRflCGPoxvnVY=";
|
||||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||||
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
distHashSri = "sha256-Z27CJz/9mmkkiEnF1R3E1ZpdW2j7unpP5+e1cqXyXxQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
gitDeps = {
|
gitDeps = {
|
||||||
@@ -17,5 +21,5 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||||
tarballHash = "";
|
tarballHash = "589b389b356a48d54ad4022e68eaac165a1de654d0b98edec79ebbab2c4a1275";
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -18,21 +18,23 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.62.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tauri-apps/cli": "^2.1.0",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"svelte": "^5.55.2",
|
"@types/node": "^25.9.3",
|
||||||
"svelte-check": "^4.4.6",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"typescript": "^6.0.2",
|
"svelte": "^5.56.1",
|
||||||
"vite": "^8.0.7"
|
"svelte-check": "^4.5.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/browser": "^8.0.3",
|
"@capacitor/browser": "^8.0.3",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.4.0",
|
||||||
"@capacitor/filesystem": "^8.1.2",
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.9",
|
"@tauri-apps/plugin-http": "^2.5.9",
|
||||||
@@ -42,7 +44,6 @@
|
|||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"capacitor-native-biometric": "^4.2.2",
|
"capacitor-native-biometric": "^4.2.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"phosphor-svelte": "^3.1.0",
|
|
||||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+240
-240
@@ -125,14 +125,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/autocfg/autocfg-1.5.0.crate",
|
"url": "https://static.crates.io/crates/autocfg/autocfg-1.5.1.crate",
|
||||||
"sha256": "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8",
|
"sha256": "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53",
|
||||||
"dest": "cargo/vendor/autocfg-1.5.0"
|
"dest": "cargo/vendor/autocfg-1.5.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8\", \"files\": {}}",
|
"contents": "{\"package\": \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/autocfg-1.5.0",
|
"dest": "cargo/vendor/autocfg-1.5.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,14 +203,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/bitflags/bitflags-2.11.1.crate",
|
"url": "https://static.crates.io/crates/bitflags/bitflags-2.13.0.crate",
|
||||||
"sha256": "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3",
|
"sha256": "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8",
|
||||||
"dest": "cargo/vendor/bitflags-2.11.1"
|
"dest": "cargo/vendor/bitflags-2.13.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3\", \"files\": {}}",
|
"contents": "{\"package\": \"b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/bitflags-2.11.1",
|
"dest": "cargo/vendor/bitflags-2.13.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -242,27 +242,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/brotli/brotli-8.0.2.crate",
|
"url": "https://static.crates.io/crates/brotli/brotli-8.0.3.crate",
|
||||||
"sha256": "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560",
|
"sha256": "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610",
|
||||||
"dest": "cargo/vendor/brotli-8.0.2"
|
"dest": "cargo/vendor/brotli-8.0.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560\", \"files\": {}}",
|
"contents": "{\"package\": \"8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/brotli-8.0.2",
|
"dest": "cargo/vendor/brotli-8.0.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.0.crate",
|
"url": "https://static.crates.io/crates/brotli-decompressor/brotli-decompressor-5.0.1.crate",
|
||||||
"sha256": "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03",
|
"sha256": "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924",
|
||||||
"dest": "cargo/vendor/brotli-decompressor-5.0.0"
|
"dest": "cargo/vendor/brotli-decompressor-5.0.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03\", \"files\": {}}",
|
"contents": "{\"package\": \"5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/brotli-decompressor-5.0.0",
|
"dest": "cargo/vendor/brotli-decompressor-5.0.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -281,14 +281,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/bumpalo/bumpalo-3.20.2.crate",
|
"url": "https://static.crates.io/crates/bumpalo/bumpalo-3.20.3.crate",
|
||||||
"sha256": "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb",
|
"sha256": "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649",
|
||||||
"dest": "cargo/vendor/bumpalo-3.20.2"
|
"dest": "cargo/vendor/bumpalo-3.20.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb\", \"files\": {}}",
|
"contents": "{\"package\": \"72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/bumpalo-3.20.2",
|
"dest": "cargo/vendor/bumpalo-3.20.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -411,14 +411,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/cc/cc-1.2.62.crate",
|
"url": "https://static.crates.io/crates/cc/cc-1.2.64.crate",
|
||||||
"sha256": "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98",
|
"sha256": "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f",
|
||||||
"dest": "cargo/vendor/cc-1.2.62"
|
"dest": "cargo/vendor/cc-1.2.64"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\", \"files\": {}}",
|
"contents": "{\"package\": \"dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/cc-1.2.62",
|
"dest": "cargo/vendor/cc-1.2.64",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -489,14 +489,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/chrono/chrono-0.4.44.crate",
|
"url": "https://static.crates.io/crates/chrono/chrono-0.4.45.crate",
|
||||||
"sha256": "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0",
|
"sha256": "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327",
|
||||||
"dest": "cargo/vendor/chrono-0.4.44"
|
"dest": "cargo/vendor/chrono-0.4.45"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0\", \"files\": {}}",
|
"contents": "{\"package\": \"1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/chrono-0.4.44",
|
"dest": "cargo/vendor/chrono-0.4.45",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -944,14 +944,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/displaydoc/displaydoc-0.2.5.crate",
|
"url": "https://static.crates.io/crates/displaydoc/displaydoc-0.2.6.crate",
|
||||||
"sha256": "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0",
|
"sha256": "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f",
|
||||||
"dest": "cargo/vendor/displaydoc-0.2.5"
|
"dest": "cargo/vendor/displaydoc-0.2.6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0\", \"files\": {}}",
|
"contents": "{\"package\": \"1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/displaydoc-0.2.5",
|
"dest": "cargo/vendor/displaydoc-0.2.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1100,14 +1100,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/either/either-1.15.0.crate",
|
"url": "https://static.crates.io/crates/either/either-1.16.0.crate",
|
||||||
"sha256": "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719",
|
"sha256": "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e",
|
||||||
"dest": "cargo/vendor/either-1.15.0"
|
"dest": "cargo/vendor/either-1.16.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719\", \"files\": {}}",
|
"contents": "{\"package\": \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/either-1.15.0",
|
"dest": "cargo/vendor/either-1.16.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1867,14 +1867,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/http/http-1.4.0.crate",
|
"url": "https://static.crates.io/crates/http/http-1.4.2.crate",
|
||||||
"sha256": "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a",
|
"sha256": "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425",
|
||||||
"dest": "cargo/vendor/http-1.4.0"
|
"dest": "cargo/vendor/http-1.4.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a\", \"files\": {}}",
|
"contents": "{\"package\": \"6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/http-1.4.0",
|
"dest": "cargo/vendor/http-1.4.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1919,14 +1919,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/hyper/hyper-1.9.0.crate",
|
"url": "https://static.crates.io/crates/hyper/hyper-1.10.1.crate",
|
||||||
"sha256": "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca",
|
"sha256": "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498",
|
||||||
"dest": "cargo/vendor/hyper-1.9.0"
|
"dest": "cargo/vendor/hyper-1.10.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca\", \"files\": {}}",
|
"contents": "{\"package\": \"55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/hyper-1.9.0",
|
"dest": "cargo/vendor/hyper-1.10.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2322,14 +2322,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.98.crate",
|
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.102.crate",
|
||||||
"sha256": "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08",
|
"sha256": "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31",
|
||||||
"dest": "cargo/vendor/js-sys-0.3.98"
|
"dest": "cargo/vendor/js-sys-0.3.102"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08\", \"files\": {}}",
|
"contents": "{\"package\": \"03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/js-sys-0.3.98",
|
"dest": "cargo/vendor/js-sys-0.3.102",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2452,14 +2452,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/libredox/libredox-0.1.16.crate",
|
"url": "https://static.crates.io/crates/libredox/libredox-0.1.17.crate",
|
||||||
"sha256": "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c",
|
"sha256": "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3",
|
||||||
"dest": "cargo/vendor/libredox-0.1.16"
|
"dest": "cargo/vendor/libredox-0.1.17"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c\", \"files\": {}}",
|
"contents": "{\"package\": \"f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/libredox-0.1.16",
|
"dest": "cargo/vendor/libredox-0.1.17",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2517,14 +2517,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/log/log-0.4.29.crate",
|
"url": "https://static.crates.io/crates/log/log-0.4.32.crate",
|
||||||
"sha256": "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897",
|
"sha256": "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a",
|
||||||
"dest": "cargo/vendor/log-0.4.29"
|
"dest": "cargo/vendor/log-0.4.32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897\", \"files\": {}}",
|
"contents": "{\"package\": \"953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/log-0.4.29",
|
"dest": "cargo/vendor/log-0.4.32",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2556,14 +2556,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/memchr/memchr-2.8.0.crate",
|
"url": "https://static.crates.io/crates/memchr/memchr-2.8.2.crate",
|
||||||
"sha256": "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79",
|
"sha256": "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4",
|
||||||
"dest": "cargo/vendor/memchr-2.8.0"
|
"dest": "cargo/vendor/memchr-2.8.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79\", \"files\": {}}",
|
"contents": "{\"package\": \"88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/memchr-2.8.0",
|
"dest": "cargo/vendor/memchr-2.8.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2608,27 +2608,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/mio/mio-1.2.0.crate",
|
"url": "https://static.crates.io/crates/mio/mio-1.2.1.crate",
|
||||||
"sha256": "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1",
|
"sha256": "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda",
|
||||||
"dest": "cargo/vendor/mio-1.2.0"
|
"dest": "cargo/vendor/mio-1.2.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1\", \"files\": {}}",
|
"contents": "{\"package\": \"02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/mio-1.2.0",
|
"dest": "cargo/vendor/mio-1.2.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/muda/muda-0.19.1.crate",
|
"url": "https://static.crates.io/crates/muda/muda-0.19.2.crate",
|
||||||
"sha256": "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb",
|
"sha256": "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c",
|
||||||
"dest": "cargo/vendor/muda-0.19.1"
|
"dest": "cargo/vendor/muda-0.19.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb\", \"files\": {}}",
|
"contents": "{\"package\": \"47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/muda-0.19.1",
|
"dest": "cargo/vendor/muda-0.19.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2686,14 +2686,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/nix/nix-0.30.1.crate",
|
"url": "https://static.crates.io/crates/nix/nix-0.31.3.crate",
|
||||||
"sha256": "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6",
|
"sha256": "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d",
|
||||||
"dest": "cargo/vendor/nix-0.30.1"
|
"dest": "cargo/vendor/nix-0.31.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6\", \"files\": {}}",
|
"contents": "{\"package\": \"cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/nix-0.30.1",
|
"dest": "cargo/vendor/nix-0.31.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2712,14 +2712,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/num-conv/num-conv-0.2.1.crate",
|
"url": "https://static.crates.io/crates/num-conv/num-conv-0.2.2.crate",
|
||||||
"sha256": "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967",
|
"sha256": "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441",
|
||||||
"dest": "cargo/vendor/num-conv-0.2.1"
|
"dest": "cargo/vendor/num-conv-0.2.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967\", \"files\": {}}",
|
"contents": "{\"package\": \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/num-conv-0.2.1",
|
"dest": "cargo/vendor/num-conv-0.2.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3024,14 +3024,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
|
"url": "https://static.crates.io/crates/openssl/openssl-0.10.81.crate",
|
||||||
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
|
"sha256": "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45",
|
||||||
"dest": "cargo/vendor/openssl-0.10.80"
|
"dest": "cargo/vendor/openssl-0.10.81"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
|
"contents": "{\"package\": \"77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-0.10.80",
|
"dest": "cargo/vendor/openssl-0.10.81",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3063,14 +3063,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
|
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.117.crate",
|
||||||
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
|
"sha256": "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.116"
|
"dest": "cargo/vendor/openssl-sys-0.9.117"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
|
"contents": "{\"package\": \"b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.116",
|
"dest": "cargo/vendor/openssl-sys-0.9.117",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3089,14 +3089,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/os_info/os_info-3.14.0.crate",
|
"url": "https://static.crates.io/crates/os_info/os_info-3.15.0.crate",
|
||||||
"sha256": "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224",
|
"sha256": "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b",
|
||||||
"dest": "cargo/vendor/os_info-3.14.0"
|
"dest": "cargo/vendor/os_info-3.15.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224\", \"files\": {}}",
|
"contents": "{\"package\": \"9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/os_info-3.14.0",
|
"dest": "cargo/vendor/os_info-3.15.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3726,14 +3726,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/regex/regex-1.12.3.crate",
|
"url": "https://static.crates.io/crates/regex/regex-1.12.4.crate",
|
||||||
"sha256": "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276",
|
"sha256": "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba",
|
||||||
"dest": "cargo/vendor/regex-1.12.3"
|
"dest": "cargo/vendor/regex-1.12.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\", \"files\": {}}",
|
"contents": "{\"package\": \"f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/regex-1.12.3",
|
"dest": "cargo/vendor/regex-1.12.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3752,14 +3752,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.10.crate",
|
"url": "https://static.crates.io/crates/regex-syntax/regex-syntax-0.8.11.crate",
|
||||||
"sha256": "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a",
|
"sha256": "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4",
|
||||||
"dest": "cargo/vendor/regex-syntax-0.8.10"
|
"dest": "cargo/vendor/regex-syntax-0.8.11"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a\", \"files\": {}}",
|
"contents": "{\"package\": \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/regex-syntax-0.8.10",
|
"dest": "cargo/vendor/regex-syntax-0.8.11",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3778,14 +3778,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.3.crate",
|
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.4.crate",
|
||||||
"sha256": "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0",
|
"sha256": "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3",
|
||||||
"dest": "cargo/vendor/reqwest-0.13.3"
|
"dest": "cargo/vendor/reqwest-0.13.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0\", \"files\": {}}",
|
"contents": "{\"package\": \"219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/reqwest-0.13.3",
|
"dest": "cargo/vendor/reqwest-0.13.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4129,14 +4129,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/serde_json/serde_json-1.0.149.crate",
|
"url": "https://static.crates.io/crates/serde_json/serde_json-1.0.150.crate",
|
||||||
"sha256": "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86",
|
"sha256": "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9",
|
||||||
"dest": "cargo/vendor/serde_json-1.0.149"
|
"dest": "cargo/vendor/serde_json-1.0.150"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86\", \"files\": {}}",
|
"contents": "{\"package\": \"e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/serde_json-1.0.149",
|
"dest": "cargo/vendor/serde_json-1.0.150",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4194,27 +4194,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/serde_with/serde_with-3.20.0.crate",
|
"url": "https://static.crates.io/crates/serde_with/serde_with-3.21.0.crate",
|
||||||
"sha256": "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2",
|
"sha256": "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c",
|
||||||
"dest": "cargo/vendor/serde_with-3.20.0"
|
"dest": "cargo/vendor/serde_with-3.21.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2\", \"files\": {}}",
|
"contents": "{\"package\": \"76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/serde_with-3.20.0",
|
"dest": "cargo/vendor/serde_with-3.21.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.20.0.crate",
|
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.21.0.crate",
|
||||||
"sha256": "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac",
|
"sha256": "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660",
|
||||||
"dest": "cargo/vendor/serde_with_macros-3.20.0"
|
"dest": "cargo/vendor/serde_with_macros-3.21.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac\", \"files\": {}}",
|
"contents": "{\"package\": \"84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/serde_with_macros-3.20.0",
|
"dest": "cargo/vendor/serde_with_macros-3.21.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4285,14 +4285,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/shlex/shlex-1.3.0.crate",
|
"url": "https://static.crates.io/crates/shlex/shlex-2.0.1.crate",
|
||||||
"sha256": "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64",
|
"sha256": "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba",
|
||||||
"dest": "cargo/vendor/shlex-1.3.0"
|
"dest": "cargo/vendor/shlex-2.0.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\", \"files\": {}}",
|
"contents": "{\"package\": \"f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/shlex-1.3.0",
|
"dest": "cargo/vendor/shlex-2.0.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4376,27 +4376,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/smallvec/smallvec-1.15.1.crate",
|
"url": "https://static.crates.io/crates/smallvec/smallvec-1.15.2.crate",
|
||||||
"sha256": "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03",
|
"sha256": "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90",
|
||||||
"dest": "cargo/vendor/smallvec-1.15.1"
|
"dest": "cargo/vendor/smallvec-1.15.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03\", \"files\": {}}",
|
"contents": "{\"package\": \"8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/smallvec-1.15.1",
|
"dest": "cargo/vendor/smallvec-1.15.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/socket2/socket2-0.6.3.crate",
|
"url": "https://static.crates.io/crates/socket2/socket2-0.6.4.crate",
|
||||||
"sha256": "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e",
|
"sha256": "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51",
|
||||||
"dest": "cargo/vendor/socket2-0.6.3"
|
"dest": "cargo/vendor/socket2-0.6.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e\", \"files\": {}}",
|
"contents": "{\"package\": \"52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/socket2-0.6.3",
|
"dest": "cargo/vendor/socket2-0.6.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4649,14 +4649,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tao/tao-0.35.2.crate",
|
"url": "https://static.crates.io/crates/tao/tao-0.35.3.crate",
|
||||||
"sha256": "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4",
|
"sha256": "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9",
|
||||||
"dest": "cargo/vendor/tao-0.35.2"
|
"dest": "cargo/vendor/tao-0.35.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4\", \"files\": {}}",
|
"contents": "{\"package\": \"d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tao-0.35.2",
|
"dest": "cargo/vendor/tao-0.35.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5239,14 +5239,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.11+spec-1.1.0.crate",
|
"url": "https://static.crates.io/crates/toml_edit/toml_edit-0.25.12+spec-1.1.0.crate",
|
||||||
"sha256": "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b",
|
"sha256": "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7",
|
||||||
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0"
|
"dest": "cargo/vendor/toml_edit-0.25.12+spec-1.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b\", \"files\": {}}",
|
"contents": "{\"package\": \"d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/toml_edit-0.25.11+spec-1.1.0",
|
"dest": "cargo/vendor/toml_edit-0.25.12+spec-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5291,14 +5291,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.10.crate",
|
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.11.crate",
|
||||||
"sha256": "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51",
|
"sha256": "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840",
|
||||||
"dest": "cargo/vendor/tower-http-0.6.10"
|
"dest": "cargo/vendor/tower-http-0.6.11"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51\", \"files\": {}}",
|
"contents": "{\"package\": \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tower-http-0.6.10",
|
"dest": "cargo/vendor/tower-http-0.6.11",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5408,14 +5408,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/typenum/typenum-1.20.0.crate",
|
"url": "https://static.crates.io/crates/typenum/typenum-1.20.1.crate",
|
||||||
"sha256": "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de",
|
"sha256": "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20",
|
||||||
"dest": "cargo/vendor/typenum-1.20.0"
|
"dest": "cargo/vendor/typenum-1.20.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de\", \"files\": {}}",
|
"contents": "{\"package\": \"b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/typenum-1.20.0",
|
"dest": "cargo/vendor/typenum-1.20.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5499,14 +5499,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/unicode-segmentation/unicode-segmentation-1.13.2.crate",
|
"url": "https://static.crates.io/crates/unicode-segmentation/unicode-segmentation-1.13.3.crate",
|
||||||
"sha256": "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c",
|
"sha256": "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8",
|
||||||
"dest": "cargo/vendor/unicode-segmentation-1.13.2"
|
"dest": "cargo/vendor/unicode-segmentation-1.13.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c\", \"files\": {}}",
|
"contents": "{\"package\": \"c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/unicode-segmentation-1.13.2",
|
"dest": "cargo/vendor/unicode-segmentation-1.13.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5616,14 +5616,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/uuid/uuid-1.23.1.crate",
|
"url": "https://static.crates.io/crates/uuid/uuid-1.23.3.crate",
|
||||||
"sha256": "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76",
|
"sha256": "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7",
|
||||||
"dest": "cargo/vendor/uuid-1.23.1"
|
"dest": "cargo/vendor/uuid-1.23.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76\", \"files\": {}}",
|
"contents": "{\"package\": \"144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/uuid-1.23.1",
|
"dest": "cargo/vendor/uuid-1.23.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5733,14 +5733,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.3+wasi-0.2.9.crate",
|
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.4+wasi-0.2.12.crate",
|
||||||
"sha256": "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6",
|
"sha256": "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487",
|
||||||
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9"
|
"dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6\", \"files\": {}}",
|
"contents": "{\"package\": \"b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9",
|
"dest": "cargo/vendor/wasip2-1.0.4+wasi-0.2.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5759,66 +5759,66 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.121.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.125.crate",
|
||||||
"sha256": "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790",
|
"sha256": "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-0.2.121"
|
"dest": "cargo/vendor/wasm-bindgen-0.2.125"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790\", \"files\": {}}",
|
"contents": "{\"package\": \"8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-0.2.121",
|
"dest": "cargo/vendor/wasm-bindgen-0.2.125",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.71.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.75.crate",
|
||||||
"sha256": "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8",
|
"sha256": "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71"
|
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.75"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8\", \"files\": {}}",
|
"contents": "{\"package\": \"503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71",
|
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.75",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.121.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.125.crate",
|
||||||
"sha256": "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578",
|
"sha256": "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121"
|
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.125"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578\", \"files\": {}}",
|
"contents": "{\"package\": \"4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121",
|
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.125",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.121.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.125.crate",
|
||||||
"sha256": "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2",
|
"sha256": "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121"
|
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.125"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2\", \"files\": {}}",
|
"contents": "{\"package\": \"fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121",
|
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.125",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.121.crate",
|
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.125.crate",
|
||||||
"sha256": "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441",
|
"sha256": "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121"
|
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.125"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441\", \"files\": {}}",
|
"contents": "{\"package\": \"23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121",
|
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.125",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5876,14 +5876,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.98.crate",
|
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.102.crate",
|
||||||
"sha256": "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa",
|
"sha256": "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d",
|
||||||
"dest": "cargo/vendor/web-sys-0.3.98"
|
"dest": "cargo/vendor/web-sys-0.3.102"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa\", \"files\": {}}",
|
"contents": "{\"package\": \"a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/web-sys-0.3.98",
|
"dest": "cargo/vendor/web-sys-0.3.102",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7137,14 +7137,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/yoke/yoke-0.8.2.crate",
|
"url": "https://static.crates.io/crates/yoke/yoke-0.8.3.crate",
|
||||||
"sha256": "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca",
|
"sha256": "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5",
|
||||||
"dest": "cargo/vendor/yoke-0.8.2"
|
"dest": "cargo/vendor/yoke-0.8.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca\", \"files\": {}}",
|
"contents": "{\"package\": \"709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/yoke-0.8.2",
|
"dest": "cargo/vendor/yoke-0.8.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7163,27 +7163,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.48.crate",
|
"url": "https://static.crates.io/crates/zerocopy/zerocopy-0.8.52.crate",
|
||||||
"sha256": "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9",
|
"sha256": "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f",
|
||||||
"dest": "cargo/vendor/zerocopy-0.8.48"
|
"dest": "cargo/vendor/zerocopy-0.8.52"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9\", \"files\": {}}",
|
"contents": "{\"package\": \"ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerocopy-0.8.48",
|
"dest": "cargo/vendor/zerocopy-0.8.52",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.48.crate",
|
"url": "https://static.crates.io/crates/zerocopy-derive/zerocopy-derive-0.8.52.crate",
|
||||||
"sha256": "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4",
|
"sha256": "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930",
|
||||||
"dest": "cargo/vendor/zerocopy-derive-0.8.48"
|
"dest": "cargo/vendor/zerocopy-derive-0.8.52"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4\", \"files\": {}}",
|
"contents": "{\"package\": \"1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zerocopy-derive-0.8.48",
|
"dest": "cargo/vendor/zerocopy-derive-0.8.52",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7215,14 +7215,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/zeroize/zeroize-1.8.2.crate",
|
"url": "https://static.crates.io/crates/zeroize/zeroize-1.9.0.crate",
|
||||||
"sha256": "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0",
|
"sha256": "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e",
|
||||||
"dest": "cargo/vendor/zeroize-1.8.2"
|
"dest": "cargo/vendor/zeroize-1.9.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0\", \"files\": {}}",
|
"contents": "{\"package\": \"e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/zeroize-1.8.2",
|
"dest": "cargo/vendor/zeroize-1.9.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+368
-355
File diff suppressed because it is too large
Load Diff
Generated
+132
-133
@@ -78,9 +78,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
@@ -117,9 +117,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -144,9 +144,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -155,9 +155,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "5.0.0"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -174,9 +174,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
@@ -205,7 +205,7 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"glib",
|
"glib",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -268,9 +268,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.62"
|
version = "1.2.64"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -317,9 +317,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -398,7 +398,7 @@ version = "0.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
@@ -411,7 +411,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -672,7 +672,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -680,9 +680,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -789,9 +789,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
@@ -1228,7 +1228,7 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -1447,9 +1447,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1804,13 +1804,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"once_cell",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1842,7 +1841,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"serde",
|
"serde",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
@@ -1904,9 +1903,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.16"
|
version = "0.1.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -1940,9 +1939,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -1963,9 +1962,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
@@ -1994,9 +1993,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
@@ -2005,7 +2004,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.9.4"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -2029,9 +2028,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -2071,7 +2070,7 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
@@ -2097,11 +2096,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.31.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2118,9 +2117,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
@@ -2169,7 +2168,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -2182,7 +2181,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -2203,7 +2202,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
@@ -2214,7 +2213,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -2247,7 +2246,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -2274,7 +2273,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -2297,7 +2296,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2308,7 +2307,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
@@ -2320,7 +2319,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
@@ -2351,7 +2350,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -2379,11 +2378,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.81"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types 0.3.2",
|
"foreign-types 0.3.2",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2410,9 +2409,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.116"
|
version = "0.9.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2428,9 +2427,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "os_info"
|
name = "os_info"
|
||||||
version = "3.14.0"
|
version = "3.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"log",
|
"log",
|
||||||
@@ -2609,7 +2608,7 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"fdeflate",
|
"fdeflate",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2682,7 +2681,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit 0.25.11+spec-1.1.0",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2880,7 +2879,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2927,9 +2926,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -2950,9 +2949,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.10"
|
version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
@@ -3004,9 +3003,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3095,7 +3094,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -3179,7 +3178,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3230,7 +3229,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3253,7 +3252,7 @@ version = "0.36.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
@@ -3331,9 +3330,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.150"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -3385,9 +3384,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -3405,9 +3404,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -3470,9 +3469,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sigchld"
|
name = "sigchld"
|
||||||
@@ -3525,15 +3524,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3724,7 +3723,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -3754,11 +3753,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.35.2"
|
version = "0.35.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
@@ -3839,7 +3838,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3904,7 +3903,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.3",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4171,7 +4170,7 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid 1.23.1",
|
"uuid 1.23.3",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4459,9 +4458,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.11+spec-1.1.0"
|
version = "0.25.12+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
@@ -4501,11 +4500,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.10"
|
version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -4596,9 +4595,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
@@ -4649,9 +4648,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.2"
|
version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
@@ -4719,9 +4718,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -4794,9 +4793,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.3+wasi-0.2.9"
|
version = "1.0.4+wasi-0.2.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen 0.57.1",
|
"wit-bindgen 0.57.1",
|
||||||
]
|
]
|
||||||
@@ -4812,9 +4811,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.121"
|
version = "0.2.125"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4825,9 +4824,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.71"
|
version = "0.4.75"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4835,9 +4834,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.121"
|
version = "0.2.125"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4845,9 +4844,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.121"
|
version = "0.2.125"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4858,9 +4857,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.121"
|
version = "0.2.125"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -4906,7 +4905,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4914,9 +4913,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5759,7 +5758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.13.0",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5862,9 +5861,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
@@ -5885,18 +5884,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.48"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.48"
|
version = "0.8.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5926,9 +5925,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.9.4"
|
version = "0.10.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
"discord-rpc:allow-disconnect",
|
"discord-rpc:allow-disconnect",
|
||||||
"discord-rpc:allow-set-activity",
|
"discord-rpc:allow-set-activity",
|
||||||
"discord-rpc:allow-clear-activity",
|
"discord-rpc:allow-clear-activity",
|
||||||
"discord-rpc:allow-is-running"
|
"discord-rpc:allow-is-running",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,58 @@ use serde::Serialize;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use sysinfo::Disks;
|
use sysinfo::Disks;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::server::resolve::suwayomi_data_dir;
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
// ── Key-value store (used by the frontend via platformService) ────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let value = store.get(&key);
|
||||||
|
Ok(value.map(|v| v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&value).map_err(|e| e.to_string())?;
|
||||||
|
store.set(key, parsed);
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if value.is_empty() {
|
||||||
|
store.delete(&key);
|
||||||
|
} else {
|
||||||
|
store.set(&key, serde_json::Value::String(value));
|
||||||
|
}
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disk / downloads storage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct StorageInfo {
|
pub struct StorageInfo {
|
||||||
pub manga_bytes: u64,
|
pub manga_bytes: u64,
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ pub fn run() {
|
|||||||
commands::backup::auto_backup_app_data,
|
commands::backup::auto_backup_app_data,
|
||||||
commands::backup::get_auto_backup_dir,
|
commands::backup::get_auto_backup_dir,
|
||||||
commands::backup::read_store_files,
|
commands::backup::read_store_files,
|
||||||
|
commands::storage::load_store,
|
||||||
|
commands::storage::save_store,
|
||||||
|
commands::storage::store_credential,
|
||||||
|
commands::storage::get_credential,
|
||||||
commands::updater::list_releases,
|
commands::updater::list_releases,
|
||||||
commands::updater::download_and_install_update,
|
commands::updater::download_and_install_update,
|
||||||
commands::biometric::windows_hello_authenticate,
|
commands::biometric::windows_hello_authenticate,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.9.4",
|
"version": "0.10.0",
|
||||||
"identifier": "io.github.MokuProject.Moku",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build:static"
|
"beforeBuildCommand": "pnpm build:static"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
|||||||
+17
-31
@@ -3,71 +3,57 @@ import { initPlatformService } from '$lib/platform-servic
|
|||||||
import { initRequestManager } from '$lib/request-manager'
|
import { initRequestManager } from '$lib/request-manager'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||||
import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist'
|
import { loadSettings, loadLibrary } from '$lib/core/persistence/persist'
|
||||||
import { loadSettingsIntoState } from '$lib/state/settings.svelte'
|
import { loadSettingsIntoState, settingsState } from '$lib/state/settings.svelte'
|
||||||
import { historyState } from '$lib/state/history.svelte'
|
import { historyState } from '$lib/state/history.svelte'
|
||||||
import { readerState } from '$lib/state/reader.svelte'
|
import { readerState } from '$lib/state/reader.svelte'
|
||||||
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
const KEY_URL = 'moku_server_url'
|
|
||||||
const KEY_AUTH = 'moku_auth_config'
|
|
||||||
|
|
||||||
interface SavedAuth {
|
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
|
||||||
user?: string
|
|
||||||
pass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveServerAdapter() {
|
|
||||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
|
||||||
return new SuwayomiAdapter()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
try {
|
try {
|
||||||
const platformAdapter = detectAdapter()
|
const platformAdapter = detectAdapter()
|
||||||
initPlatformService(platformAdapter)
|
initPlatformService(platformAdapter)
|
||||||
|
|
||||||
await platformAdapter.init()
|
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||||
|
const serverAdapter = new SuwayomiAdapter()
|
||||||
const serverAdapter = await resolveServerAdapter()
|
|
||||||
initRequestManager(serverAdapter)
|
initRequestManager(serverAdapter)
|
||||||
|
|
||||||
|
await platformAdapter.init()
|
||||||
|
|
||||||
appState.platform = platformAdapter.platform
|
appState.platform = platformAdapter.platform
|
||||||
appState.version = await platformAdapter.getVersion()
|
appState.version = await platformAdapter.getVersion()
|
||||||
|
|
||||||
const [settingsData, libraryData] = await Promise.all([
|
const [settingsData, libraryData] = await Promise.all([
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
loadLibrary(),
|
loadLibrary(),
|
||||||
loadUpdates(),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
await loadSettingsIntoState(settingsData.settings)
|
await loadSettingsIntoState(settingsData.settings)
|
||||||
|
|
||||||
readerState.bookmarks = libraryData.bookmarks
|
seriesState.bookmarks = libraryData.bookmarks
|
||||||
readerState.markers = libraryData.markers
|
readerState.markers = libraryData.markers
|
||||||
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
||||||
|
|
||||||
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
|
const savedUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
||||||
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
|
const authMode = settingsState.settings.serverAuthMode ?? 'NONE'
|
||||||
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
|
const authUser = settingsState.settings.serverAuthUser || undefined
|
||||||
|
const authPass = settingsState.settings.serverAuthPass || undefined
|
||||||
|
|
||||||
appState.serverUrl = savedUrl
|
appState.serverUrl = savedUrl
|
||||||
appState.authMode = savedAuth.mode
|
appState.authMode = authMode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : authMode
|
||||||
appState.authUser = savedAuth.user ?? ''
|
|
||||||
appState.authPass = savedAuth.pass ?? ''
|
|
||||||
|
|
||||||
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
configureAuth(savedUrl, authMode, authUser, authPass)
|
||||||
|
|
||||||
await serverAdapter.connect({
|
await serverAdapter.connect({
|
||||||
baseUrl: savedUrl,
|
baseUrl: savedUrl,
|
||||||
credentials:
|
credentials:
|
||||||
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass
|
authMode === 'BASIC_AUTH' && authUser && authPass
|
||||||
? { username: savedAuth.user, password: savedAuth.pass }
|
? { username: authUser, password: authPass }
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTauri = platformAdapter.platform === 'tauri'
|
const isTauri = platformAdapter.platform === 'tauri'
|
||||||
const autoStartServer = settingsData.settings.autoStartServer ?? false
|
const autoStartServer = settingsState.settings.autoStartServer
|
||||||
|
|
||||||
if (isTauri && autoStartServer) {
|
if (isTauri && autoStartServer) {
|
||||||
appState.status = 'booting'
|
appState.status = 'booting'
|
||||||
|
|||||||
@@ -6,14 +6,25 @@
|
|||||||
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
|
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
import { ArrowLeftIcon, BookmarkSimpleIcon, FolderSimplePlusIcon, FolderIcon, CircleNotchIcon } from "phosphor-svelte";
|
||||||
import type { Manga, Source, Category } from "$lib/types";
|
import type { Manga, Source, Category } from "$lib/types";
|
||||||
import type { MenuEntry } from "$lib/components/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 "$lib/components/browse/lib/searchFilter";
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
icon?: any;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
separator?: never;
|
||||||
|
children?: MenuEntry[];
|
||||||
|
}
|
||||||
|
interface MenuSeparator { separator: true }
|
||||||
|
type MenuEntry = MenuItem | MenuSeparator;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
genre: string;
|
genre: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -63,17 +74,17 @@
|
|||||||
const t = parseTags(filter);
|
const t = parseTags(filter);
|
||||||
const pt = t[0] ?? "";
|
const pt = t[0] ?? "";
|
||||||
|
|
||||||
getAdapter().getMangaList({}).then((result) => {
|
getAdapter().getMangaList({}).then((result: { items: Manga[] }) => {
|
||||||
if (!ctrl.signal.aborted) libraryManga = result.items;
|
if (!ctrl.signal.aborted) libraryManga = result.items;
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
getAdapter().getSources().then(async (allSources) => {
|
getAdapter().getSources().then(async (allSources: Source[]) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const srcs = allSources.filter((s: Source) => s.id !== "0").slice(0, MAX_SOURCES);
|
const srcs = allSources.filter((s: Source) => s.id !== "0").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) => {
|
await runConcurrent(srcs, async (src: Source) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const pageItems: Manga[] = [];
|
const pageItems: Manga[] = [];
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||||
@@ -108,7 +119,7 @@
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortCtrl = ctrl;
|
abortCtrl = ctrl;
|
||||||
try {
|
try {
|
||||||
await runConcurrent(srcs, async (src) => {
|
await runConcurrent(srcs, async (src: Source) => {
|
||||||
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;
|
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
|
||||||
@@ -131,7 +142,7 @@
|
|||||||
if (!catsLoaded) {
|
if (!catsLoaded) {
|
||||||
catsLoaded = true;
|
catsLoaded = true;
|
||||||
getAdapter().getCategories()
|
getAdapter().getCategories()
|
||||||
.then((cats) => { categories = cats.filter((c) => c.id !== 0); })
|
.then((cats: Category[]) => { categories = cats.filter((c: Category) => c.id !== 0); })
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +151,7 @@
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
icon: BookmarkSimple,
|
icon: BookmarkSimpleIcon,
|
||||||
disabled: m.inLibrary,
|
disabled: m.inLibrary,
|
||||||
onClick: () => getAdapter().addToLibrary(String(m.id))
|
onClick: () => getAdapter().addToLibrary(String(m.id))
|
||||||
.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); })
|
||||||
@@ -149,15 +160,15 @@
|
|||||||
...(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?.nodes ?? []).some((x: { id: number }) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
label: (cat.mangas ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||||
icon: Folder,
|
icon: FolderIcon,
|
||||||
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
|
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "New folder & add",
|
label: "New folder & add",
|
||||||
icon: FolderSimplePlus,
|
icon: FolderSimplePlusIcon,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const name = prompt("Folder name:");
|
const name = prompt("Folder name:");
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
@@ -177,7 +188,7 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" onclick={onBack}>
|
<button class="back" onclick={onBack}>
|
||||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
<ArrowLeftIcon 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}
|
||||||
@@ -204,7 +215,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} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#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>
|
||||||
@@ -213,7 +224,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}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
{#if loadingMore}<CircleNotchIcon size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -239,7 +250,7 @@
|
|||||||
.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; -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; 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%; }
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
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;
|
||||||
@@ -57,32 +59,37 @@
|
|||||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||||
const q = pendingPrefill;
|
const q = pendingPrefill;
|
||||||
onPrefillConsumed();
|
onPrefillConsumed();
|
||||||
|
kw_localQuery = q;
|
||||||
onQueryChange(q);
|
onQueryChange(q);
|
||||||
kwDoSearch(q);
|
kwDoSearch(q);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
function kwHandleInput(value: string) {
|
||||||
const q = query;
|
kw_localQuery = value;
|
||||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
|
||||||
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
kw_pending = true;
|
||||||
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
kw_debounceTimer = setTimeout(() => {
|
||||||
});
|
kw_pending = false;
|
||||||
|
onQueryChange(value);
|
||||||
|
kwDoSearch(value);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
function kwGetVisibleSources(): Source[] {
|
const kw_visibleSources = $derived.by(() => {
|
||||||
let srcs = allSources;
|
let srcs = allSources;
|
||||||
if (kw_selectedLangs.size > 0)
|
if (kw_selectedLangs.size > 0)
|
||||||
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
|
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
|
||||||
if (settingsState.settings.contentLevel !== "unrestricted")
|
if (settingsState.settings.contentLevel !== "unrestricted")
|
||||||
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
|
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
|
||||||
return srcs;
|
return srcs;
|
||||||
}
|
});
|
||||||
|
|
||||||
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 = kwGetVisibleSources();
|
const visible = kw_visibleSources;
|
||||||
if (!visible.length) return;
|
if (!visible.length) return;
|
||||||
|
|
||||||
kw_abortCtrl?.abort();
|
kw_abortCtrl?.abort();
|
||||||
@@ -95,13 +102,13 @@
|
|||||||
await Promise.allSettled(visible.map(async (src) => {
|
await Promise.allSettled(visible.map(async (src) => {
|
||||||
const idx = idxOf.get(src.id)!;
|
const idx = idxOf.get(src.id)!;
|
||||||
try {
|
try {
|
||||||
const result = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
|
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 mangas = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
|
||||||
kw_results = kw_results.map((r, i) => i === idx ? { ...r, mangas, loading: false } : r);
|
kw_results[idx] = { ...kw_results[idx], mangas, loading: false };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
kw_results = kw_results.map((r, i) => i === idx ? { ...r, loading: false, error: e.message ?? "Error" } : r);
|
kw_results[idx] = { ...kw_results[idx], loading: false, error: e.message ?? "Error" };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@
|
|||||||
kw_selectedLangs = next;
|
kw_selectedLangs = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
const kw_visibleCount = $derived(kw_visibleSources.length);
|
||||||
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
|
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_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));
|
||||||
@@ -142,18 +149,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:this={kw_inputEl}
|
bind:this={kw_inputEl}
|
||||||
value={query}
|
value={kw_localQuery}
|
||||||
oninput={(e) => onQueryChange((e.target as HTMLInputElement).value)}
|
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
use:focusOnMount
|
|
||||||
/>
|
/>
|
||||||
{#if kw_anyLoading}
|
{#if kw_pending || 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 query}
|
{:else if kw_localQuery}
|
||||||
<button class="clearBtn" title="Clear" onclick={() => { onQueryChange(""); kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasMultipleLangs}
|
{#if hasMultipleLangs}
|
||||||
<button
|
<button
|
||||||
@@ -193,7 +199,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !query.trim()}
|
{#if !kw_localQuery.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}
|
||||||
@@ -206,7 +212,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} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
<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">
|
||||||
@@ -235,16 +241,20 @@
|
|||||||
</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" : ""}</span>
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</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} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
<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">
|
||||||
@@ -264,16 +274,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if kw_allDone && !kw_hasResults}
|
{:else if kw_allDone && !kw_hasResults}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="emptyText">No results for "{query.trim()}"</p>
|
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">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); }
|
||||||
@@ -306,7 +315,7 @@
|
|||||||
.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; -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; 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%; }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { setPreviewManga } from "$lib/state/series.svelte";
|
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||||
import { dedupeMangaById } from "$lib/core/util";
|
|
||||||
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
|
||||||
import type { Manga, Source } from "$lib/types";
|
import type { Manga, Source } from "$lib/types";
|
||||||
|
|
||||||
@@ -39,6 +38,8 @@
|
|||||||
goto(u.toString(), { replaceState: true, noScroll: true });
|
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 });
|
||||||
|
|
||||||
@@ -64,22 +65,31 @@
|
|||||||
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()
|
getAdapter().getSources()
|
||||||
.then((nodes) => {
|
.then((nodes: Source[]) => {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
|
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
|
||||||
allSources = nodes.filter((s: Source) => s.id !== "0");
|
allSources = nodes.filter((s: Source) => s.id !== "0");
|
||||||
startSourceCacheBuild();
|
startSourceCacheBuild();
|
||||||
popularStart(allSources);
|
popularStart(allSources);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { if (!ctrl.signal.aborted) 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_abortCtrl: AbortController | null = null;
|
let popular_abortCtrl: AbortController | null = null;
|
||||||
let popular_sourcePool: Source[] = $state([]);
|
let popular_sourcePool: Source[] = [];
|
||||||
let popular_sourceCursor = $state(0);
|
let popular_sourceCursor = 0;
|
||||||
let popular_seenIds = new Set<number>();
|
let popular_seenIds = new Set<number>();
|
||||||
let popular_seenTitles = new Set<string>();
|
let popular_seenTitles = new Set<string>();
|
||||||
|
|
||||||
@@ -208,6 +218,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
sourcesAbort?.abort();
|
||||||
popular_abortCtrl?.abort();
|
popular_abortCtrl?.abort();
|
||||||
sourceCacheAbort?.abort();
|
sourceCacheAbort?.abort();
|
||||||
});
|
});
|
||||||
@@ -248,11 +259,13 @@
|
|||||||
{availableLangs}
|
{availableLangs}
|
||||||
{hasMultipleLangs}
|
{hasMultipleLangs}
|
||||||
{loadingSources}
|
{loadingSources}
|
||||||
|
{pendingPrefill}
|
||||||
popularResults={popular_results}
|
popularResults={popular_results}
|
||||||
popularLoading={popular_loading}
|
popularLoading={popular_loading}
|
||||||
{sourceCache}
|
{sourceCache}
|
||||||
query={urlQuery}
|
query={urlQuery}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
|
onPrefillConsumed={() => { pendingPrefill = ""; }}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
/>
|
/>
|
||||||
{:else if urlTab === "tag"}
|
{:else if urlTab === "tag"}
|
||||||
@@ -263,7 +276,6 @@
|
|||||||
{sourceCacheLoading}
|
{sourceCacheLoading}
|
||||||
{sourceCacheEnriching}
|
{sourceCacheEnriching}
|
||||||
onPreview={(m) => setPreviewManga(m)}
|
onPreview={(m) => setPreviewManga(m)}
|
||||||
onGenreDrill={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<SourceTab
|
<SourceTab
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
|
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
|
||||||
|
|
||||||
let src_selectedLang = $state(preferredLang || "all");
|
let src_selectedLang = $state(settingsState.settings.preferredExtensionLang || "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);
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
function togglePinnedSource(id: string) {
|
function togglePinnedSource(id: string) {
|
||||||
const current = settingsState.settings.pinnedSourceIds ?? [];
|
const current = settingsState.settings.pinnedSourceIds ?? [];
|
||||||
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
|
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
|
||||||
settingsState.updateSettings({ pinnedSourceIds: next });
|
updateSettings({ pinnedSourceIds: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => { src_abortCtrl?.abort(); });
|
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||||
@@ -261,7 +261,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} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#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>
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
.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; -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; 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); }
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
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);
|
const renderLimit = $derived(settingsState.settings.renderLimit ?? 48);
|
||||||
|
|
||||||
@@ -52,9 +53,6 @@
|
|||||||
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) {
|
||||||
@@ -62,6 +60,7 @@
|
|||||||
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;
|
||||||
@@ -77,6 +76,7 @@
|
|||||||
tag_totalCount = d.totalCount;
|
tag_totalCount = d.totalCount;
|
||||||
tag_localHasNext = d.hasNextPage;
|
tag_localHasNext = d.hasNextPage;
|
||||||
tag_localOffset = limit;
|
tag_localOffset = limit;
|
||||||
|
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 {
|
||||||
@@ -86,10 +86,10 @@
|
|||||||
|
|
||||||
async function tagLoadMoreLocal() {
|
async function tagLoadMoreLocal() {
|
||||||
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||||
tag_loadingMoreLocal = true;
|
tag_abortLoadMore?.abort();
|
||||||
tag_abortLocal?.abort();
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
tag_abortLocal = ctrl;
|
tag_abortLoadMore = ctrl;
|
||||||
|
tag_loadingMoreLocal = true;
|
||||||
const limit = renderLimit;
|
const limit = renderLimit;
|
||||||
try {
|
try {
|
||||||
const d = await getAdapter().getMangasByGenre(
|
const d = await getAdapter().getMangasByGenre(
|
||||||
@@ -191,13 +191,22 @@
|
|||||||
|
|
||||||
let tag_autoSearchFired = $state(false);
|
let tag_autoSearchFired = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
tag_activeTags;
|
void tag_activeTags;
|
||||||
tag_activeStatuses;
|
void tag_activeStatuses;
|
||||||
untrack(() => { tag_autoSearchFired = false; });
|
untrack(() => { tag_autoSearchFired = false; });
|
||||||
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
});
|
||||||
if (tag_localResults.length < 20) {
|
$effect(() => {
|
||||||
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
const _loadingLocal = tag_loadingLocal;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +239,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
tag_abortLocal?.abort();
|
tag_abortLocal?.abort();
|
||||||
|
tag_abortLoadMore?.abort();
|
||||||
tag_fanOutAbort?.abort();
|
tag_fanOutAbort?.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -360,7 +370,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} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#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>
|
||||||
@@ -370,6 +380,10 @@
|
|||||||
{#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}
|
||||||
@@ -426,9 +440,12 @@
|
|||||||
.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; -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; 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 } }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if appState.status === 'auth'}
|
{#if appState.status === 'auth'}
|
||||||
<div class="overlay">
|
<div class="overlay overlay--clear">
|
||||||
<div class="card anim-scale-in">
|
<div class="card anim-scale-in">
|
||||||
<img src={logoUrl} alt="Moku" class="logo" />
|
<img src={logoUrl} alt="Moku" class="logo" />
|
||||||
<p class="title">moku</p>
|
<p class="title">moku</p>
|
||||||
@@ -56,10 +56,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
|
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.7); backdrop-filter:blur(6px); animation:overlayIn 0.28s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
|
.overlay--clear { background:transparent; backdrop-filter:none; pointer-events:none; }
|
||||||
|
.overlay--clear .card { pointer-events:auto; }
|
||||||
|
|
||||||
|
.card { width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||||
|
|
||||||
|
.logo { width:56px; height:56px; border-radius:14px; display:block; position:relative; }
|
||||||
|
|
||||||
.logo { width:56px; height:56px; border-radius:14px; display:block; }
|
|
||||||
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||||
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||||
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||||
@@ -75,4 +79,9 @@
|
|||||||
.btn:disabled { opacity:0.35; cursor:default; }
|
.btn:disabled { opacity:0.35; cursor:default; }
|
||||||
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||||
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||||
|
|
||||||
|
@keyframes overlayIn { from { opacity:0 } to { opacity:1 } }
|
||||||
|
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||||
|
@keyframes anim-scale-in { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } }
|
||||||
|
.anim-scale-in { animation:anim-scale-in 0.2s cubic-bezier(0,0,0.2,1) both; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,213 +1,219 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
function getLiveSet(): Set<HTMLCanvasElement> {
|
||||||
|
const g = window as any
|
||||||
|
if (!g.__splashCanvasSet) g.__splashCanvasSet = new Set<HTMLCanvasElement>()
|
||||||
|
return g.__splashCanvasSet as Set<HTMLCanvasElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSet(set: Set<HTMLCanvasElement>) {
|
||||||
|
for (const el of set) if (!el.isConnected) set.delete(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevRegister(el: HTMLCanvasElement) {
|
||||||
|
const set = getLiveSet()
|
||||||
|
pruneSet(set)
|
||||||
|
set.add(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevUnregister(el: HTMLCanvasElement) {
|
||||||
|
const set = getLiveSet()
|
||||||
|
set.delete(el)
|
||||||
|
pruneSet(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevLiveCount(): number {
|
||||||
|
const set = getLiveSet()
|
||||||
|
pruneSet(set)
|
||||||
|
return set.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevNextMount(): number {
|
||||||
|
const g = window as any
|
||||||
|
g.__splashTotalMounts = (g.__splashTotalMounts ?? 0) + 1
|
||||||
|
return g.__splashTotalMounts as number
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from 'svelte'
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { platformService } from '$lib/platform-service'
|
||||||
import logoUrl from "$lib/assets/moku-icon-splash.svg";
|
|
||||||
|
const isTauri = platformService.platform === 'tauri'
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: "loading" | "idle";
|
mode?: 'loading' | 'idle' | 'locked'
|
||||||
ringFull?: boolean;
|
ringFull?: boolean
|
||||||
failed?: boolean;
|
failed?: boolean
|
||||||
notConfigured?: boolean;
|
notConfigured?: boolean
|
||||||
showCards?: boolean;
|
showCards?: boolean
|
||||||
showFps?: boolean;
|
showFps?: boolean
|
||||||
onReady?: () => void;
|
showDevOverlay?: boolean
|
||||||
onRetry?: () => void;
|
pinLen?: number
|
||||||
onBypass?: () => void;
|
pinCorrect?: string
|
||||||
onDismiss?: () => void;
|
onReady?: () => void
|
||||||
|
onUnlock?: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onBypass?: () => void
|
||||||
|
onDismiss?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
mode = "loading", ringFull = false, failed = false,
|
mode = 'loading', ringFull = false, failed = false,
|
||||||
notConfigured = false, showCards = true, showFps = false,
|
notConfigured = false, showCards = true, showFps = false, showDevOverlay = false,
|
||||||
onReady, onRetry, onBypass, onDismiss,
|
pinLen = 4, pinCorrect = '',
|
||||||
}: Props = $props();
|
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const serverAuthActive = $derived(
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
||||||
settingsState.settings.serverAuthMode === "BASIC_AUTH" || settingsState.settings.serverAuthMode === "UI_LOGIN"
|
let dots = $state('')
|
||||||
);
|
let ringProg = $state(0.025)
|
||||||
|
let exiting = $state(false)
|
||||||
|
let exitLock = false
|
||||||
|
|
||||||
const lockEnabled = $derived(
|
let pinEntry = $state('')
|
||||||
settingsState.settings.appLockEnabled &&
|
let pinShake = $state(false)
|
||||||
(settingsState.settings.appLockPin?.length ?? 0) >= 4 &&
|
|
||||||
(mode === "idle" || !serverAuthActive)
|
|
||||||
);
|
|
||||||
|
|
||||||
let pinEntry = $state("");
|
const logoLoadingSize = 140
|
||||||
let pinShake = $state(false);
|
const logoIdleSize = 128
|
||||||
let pinUnlocked = $state(false);
|
const ringR = 70
|
||||||
let pinVisible = $state(false);
|
const ringPad = 12
|
||||||
let uiScale = $state(1);
|
const ringSize = (ringR + ringPad) * 2
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
const ringC = ringR + ringPad
|
||||||
|
const ringCirc = 2 * Math.PI * ringR
|
||||||
|
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
|
||||||
|
|
||||||
const logoLoadingSize = 140;
|
const EXIT_MS = 320
|
||||||
const logoIdleSize = 128;
|
const PHASE1_TARGET = 0.85
|
||||||
const logoLockSize = 96;
|
const PHASE1_MS = 3000
|
||||||
|
const PHASE2_TARGET = 0.95
|
||||||
|
const PHASE2_MS = 10000
|
||||||
|
|
||||||
const ringR = 70;
|
function triggerExit(cb?: () => void) {
|
||||||
const ringPad = 12;
|
console.log('[splash] triggerExit called — exitLock:', exitLock, 'mode:', mode, 'cb:', cb?.name ?? String(cb))
|
||||||
const ringSize = (ringR + ringPad) * 2;
|
if (exitLock) { console.log('[splash] triggerExit blocked by exitLock'); return }
|
||||||
const ringC = ringR + ringPad;
|
exitLock = true
|
||||||
const ringCirc = 2 * Math.PI * ringR;
|
exiting = true
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
setTimeout(() => { console.log('[splash] triggerExit timeout — calling cb'); cb?.() }, EXIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
let animFrame = 0
|
||||||
|
let animStart: number | null = null
|
||||||
|
let animPhase = 1
|
||||||
|
|
||||||
|
function animateRing(ts: number) {
|
||||||
|
if (exitLock) return
|
||||||
|
if (animStart === null) animStart = ts
|
||||||
|
const elapsed = ts - animStart
|
||||||
|
if (animPhase === 1) {
|
||||||
|
const t = Math.min(elapsed / PHASE1_MS, 1)
|
||||||
|
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025)
|
||||||
|
if (t >= 1) { animPhase = 2; animStart = ts }
|
||||||
|
} else {
|
||||||
|
const t = Math.min(elapsed / PHASE2_MS, 1)
|
||||||
|
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET)
|
||||||
|
}
|
||||||
|
animFrame = requestAnimationFrame(animateRing)
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (mode !== 'loading' || failed || notConfigured || ringFull || !isTauri) return
|
||||||
|
animStart = null
|
||||||
|
animPhase = 1
|
||||||
|
animFrame = requestAnimationFrame(animateRing)
|
||||||
|
return () => cancelAnimationFrame(animFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
console.log('[splash] ringFull effect — ringFull:', ringFull, 'mode:', mode, 'exitLock:', exitLock)
|
||||||
|
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
||||||
|
cancelAnimationFrame(animFrame)
|
||||||
|
animFrame = 0
|
||||||
|
ringProg = 1
|
||||||
|
const t = setTimeout(() => { console.log('[splash] ringFull timeout firing — calling triggerExit(onReady)'); triggerExit(onReady) }, 650)
|
||||||
|
return () => { console.log('[splash] ringFull effect cleanup — cancelling timeout'); clearTimeout(t) }
|
||||||
|
})
|
||||||
|
|
||||||
function submitPin() {
|
function submitPin() {
|
||||||
if (pinEntry === settingsState.settings.appLockPin) {
|
if (pinEntry === pinCorrect) {
|
||||||
pinUnlocked = true;
|
triggerExit(onUnlock)
|
||||||
pinEntry = "";
|
|
||||||
if (mode === "idle") triggerExit(onDismiss);
|
|
||||||
} else {
|
} else {
|
||||||
pinShake = true;
|
pinShake = true
|
||||||
pinEntry = "";
|
pinEntry = ''
|
||||||
setTimeout(() => (pinShake = false), 500);
|
setTimeout(() => (pinShake = false), 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPinKey(e: KeyboardEvent) {
|
function onPinKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") { submitPin(); return; }
|
if (mode !== 'locked' || exitLock) return
|
||||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
if (e.key === 'Enter') { e.preventDefault(); submitPin(); return }
|
||||||
|
if (e.key === 'Backspace') { e.preventDefault(); pinEntry = pinEntry.slice(0, -1); return }
|
||||||
if (/^\d$/.test(e.key)) {
|
if (/^\d$/.test(e.key)) {
|
||||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
e.preventDefault()
|
||||||
if (pinEntry.length >= (settingsState.settings.appLockPin?.length ?? 4)) submitPin();
|
pinEntry = (pinEntry + e.key).slice(0, 8)
|
||||||
|
if (pinEntry.length >= pinLen) submitPin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
|
||||||
} else {
|
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
||||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
}
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mode === "loading" && !failed && !notConfigured && !ringFull) {
|
if (mode !== 'locked') return
|
||||||
animStart = null;
|
pinEntry = ''
|
||||||
animPhase = 1;
|
window.addEventListener('keydown', onPinKey)
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
return () => window.removeEventListener('keydown', onPinKey)
|
||||||
return () => cancelAnimationFrame(animFrame);
|
})
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (!ringFull) {
|
const iv = setInterval(() => { dots = dots.length >= 3 ? '' : dots + '.' }, 420)
|
||||||
exitLock = false;
|
|
||||||
exiting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimationFrame(animFrame);
|
if (mode === 'idle' && onDismiss) {
|
||||||
animFrame = 0;
|
const handler = () => triggerExit(onDismiss)
|
||||||
ringProg = 1;
|
|
||||||
if (lockEnabled && !pinUnlocked) {
|
|
||||||
setTimeout(() => (pinVisible = true), 400);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const needsPin =
|
|
||||||
(mode === "idle" && lockEnabled) ||
|
|
||||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
|
||||||
if (!needsPin) return;
|
|
||||||
window.addEventListener("keydown", onPinKey);
|
|
||||||
return () => window.removeEventListener("keydown", onPinKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
uiScale = await win.scaleFactor();
|
|
||||||
} catch {
|
|
||||||
uiScale = window.devicePixelRatio || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
if (lockEnabled) return () => clearInterval(dotsInterval);
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
window.addEventListener('keydown', handler, { once: true })
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
window.addEventListener('mousedown', handler, { once: true })
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
window.addEventListener('touchstart', handler, { once: true })
|
||||||
}, 200);
|
}, 200)
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t)
|
||||||
clearInterval(dotsInterval);
|
clearInterval(iv)
|
||||||
window.removeEventListener("keydown", handler);
|
window.removeEventListener('keydown', handler)
|
||||||
window.removeEventListener("mousedown", handler);
|
window.removeEventListener('mousedown', handler)
|
||||||
window.removeEventListener("touchstart", handler);
|
window.removeEventListener('touchstart', handler)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
return () => clearInterval(dotsInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
return () => clearInterval(iv)
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
})
|
||||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
|
||||||
|
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number }
|
||||||
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number }
|
||||||
|
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number }
|
||||||
|
|
||||||
const LAYER_CFG = [
|
const LAYER_CFG = [
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||||
] as const;
|
] as const
|
||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
const BUF = 80, COLS = 14
|
||||||
|
|
||||||
function hash(n: number): number {
|
function hash(n: number): number {
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b)
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b)
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
function buildCards(vw: number, vh: number) {
|
||||||
const cards: CardDef[] = [];
|
const cards: CardDef[] = []
|
||||||
const laneW = vw / COLS;
|
const laneW = vw / COLS
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
const cfg = LAYER_CFG[layer];
|
const cfg = LAYER_CFG[layer]
|
||||||
for (let col = 0; col < COLS; col++) {
|
for (let col = 0; col < COLS; col++) {
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
const seed = col * 31 + layer * 97 + 7
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin)
|
||||||
const h = w * 1.44;
|
const h = w * 1.44
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin)
|
||||||
const travel = vh + h + BUF;
|
const travel = vh + h + BUF
|
||||||
cards.push({
|
cards.push({
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||||
w, h,
|
w, h,
|
||||||
@@ -220,165 +226,245 @@
|
|||||||
yStart: vh + h / 2 + BUF / 2,
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
}));
|
}))
|
||||||
return { cards, trigs };
|
return { cards, trigs }
|
||||||
}
|
}
|
||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
ctx.beginPath();
|
ctx.beginPath()
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r)
|
||||||
ctx.closePath();
|
ctx.closePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
const STAMP_PAD = 6
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement('canvas')
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr)
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr)
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext('2d')!
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr)
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
const x0 = STAMP_PAD, y0 = STAMP_PAD
|
||||||
const coverH = c.w * 0.72 * 1.05;
|
const coverH = c.w * 0.72 * 1.05
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
const lineY0 = y0 + 3 + coverH + 5
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = 'rgba(0,0,0,0.5)'; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = 'rgba(255,255,255,0.07)'; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill()
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke()
|
||||||
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
ctx.fillStyle = 'rgba(255,255,255,0.15)'; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
ctx.fillStyle = 'rgba(255,255,255,0.08)'; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
for (let li = 0; li < c.lines; li++) {
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
ctx.fillStyle = li === 0 ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.20)'
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2)
|
||||||
}
|
}
|
||||||
return oc;
|
return oc
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement('canvas')
|
||||||
oc.width = Math.round(vw * dpr);
|
oc.width = Math.round(vw * dpr)
|
||||||
oc.height = Math.round(vh * dpr);
|
oc.height = Math.round(vh * dpr)
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext('2d')!
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr)
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65)
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
g.addColorStop(0, 'rgba(0,0,0,0)')
|
||||||
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
g.addColorStop(0.4, 'rgba(0,0,0,0)')
|
||||||
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
g.addColorStop(0.7, 'rgba(0,0,0,0.25)')
|
||||||
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
g.addColorStop(1, 'rgba(0,0,0,0.65)')
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g
|
||||||
ctx.fillRect(0, 0, vw, vh);
|
ctx.fillRect(0, 0, vw, vh)
|
||||||
return oc;
|
return oc
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFrame(
|
function drawFrame(
|
||||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
||||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
||||||
) {
|
) {
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch)
|
||||||
for (let i = 0; i < cards.length; i++) {
|
for (let i = 0; i < cards.length; i++) {
|
||||||
const c = cards[i];
|
const c = cards[i]
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
const p = ((t / c.cycleSec) + c.phase) % 1
|
||||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha
|
||||||
if (alpha < 0.005) continue;
|
if (alpha < 0.005) continue
|
||||||
const cy = c.yStart - p * c.travel;
|
const cy = c.yStart - p * c.travel
|
||||||
const tg = trigs[i];
|
const tg = trigs[i]
|
||||||
const delta = tg.tiltRad * p;
|
const delta = tg.tiltRad * p
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta)
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta)
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr)
|
||||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh)
|
||||||
}
|
}
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
ctx.drawImage(vignette, 0, 0, cw, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
let fpsFrames = 0, fpsLast = -1
|
||||||
function tickFps(now: number) {
|
function tickFps(now: number) {
|
||||||
fpsFrames++;
|
if (fpsLast < 0) { fpsLast = now; return }
|
||||||
|
fpsFrames++
|
||||||
if (now - fpsLast >= 500) {
|
if (now - fpsLast >= 500) {
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
const fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
|
||||||
fpsFrames = 0;
|
fpsFrames = 0
|
||||||
fpsLast = now;
|
fpsLast = now
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
if (fpsEl) fpsEl.textContent = `${fps} fps`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DevMetrics {
|
||||||
|
totalMounts: number
|
||||||
|
resizeCount: number
|
||||||
|
stampCount: number
|
||||||
|
mountedAt: number
|
||||||
|
lastResizeAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let devMetrics = $state<DevMetrics | null>(null)
|
||||||
|
let uptimeSecs = $state(0)
|
||||||
|
let devLiveCount = $state(0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isDev || mode !== 'idle' || !devMetrics) return
|
||||||
|
const start = Date.now()
|
||||||
|
const iv = setInterval(() => { uptimeSecs = Math.floor((Date.now() - start) / 1000) }, 1000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtUptime(s: number): string {
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(ts: number | null): string {
|
||||||
|
if (ts === null) return '—'
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000)
|
||||||
|
if (s < 60) return `${s}s ago`
|
||||||
|
return `${Math.floor(s / 60)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
function mountCanvas(el: HTMLCanvasElement) {
|
||||||
const ctx = el.getContext("2d")!;
|
const ctx = el.getContext('2d')!
|
||||||
let live: RenderState | null = null;
|
let live: RenderState | null = null
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
||||||
|
|
||||||
async function syncSize() {
|
if (isDev && mode === 'idle') {
|
||||||
const gen = ++buildGen;
|
splashDevRegister(el)
|
||||||
const scale = window.devicePixelRatio || 1;
|
devLiveCount = splashDevLiveCount()
|
||||||
const logW = el.offsetWidth || el.parentElement?.offsetWidth || 800;
|
uptimeSecs = 0
|
||||||
const logH = el.offsetHeight || el.parentElement?.offsetHeight || 600;
|
devMetrics = {
|
||||||
const phys = { width: Math.round(logW * scale), height: Math.round(logH * scale) };
|
totalMounts: splashDevNextMount(),
|
||||||
if (gen !== buildGen) return;
|
resizeCount: 0,
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
stampCount: 0,
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
mountedAt: Date.now(),
|
||||||
const built = buildCards(logW, logH);
|
lastResizeAt: null,
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
}
|
||||||
const vig = buildVignette(logW, logH, scale);
|
|
||||||
el.width = phys.width; el.height = phys.height;
|
|
||||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
function cleanup() {
|
||||||
ro.observe(el);
|
if (live) {
|
||||||
syncSize();
|
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
|
||||||
|
live.vignette.width = 0
|
||||||
|
live.vignette.height = 0
|
||||||
|
live = null
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, el.width, el.height)
|
||||||
|
}
|
||||||
|
|
||||||
let raf = 0, t0 = -1, paused = false;
|
function applySize(logW: number, logH: number, scale: number) {
|
||||||
|
const gen = ++buildGen
|
||||||
|
if (logW <= 0 || logH <= 0) return
|
||||||
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
||||||
|
lastLogW = logW; lastLogH = logH; lastScale = scale
|
||||||
|
if (live) cleanup()
|
||||||
|
const built = buildCards(logW, logH)
|
||||||
|
const stamps = built.cards.map(c => buildStamp(c, scale))
|
||||||
|
const vig = buildVignette(logW, logH, scale)
|
||||||
|
el.width = Math.round(logW * scale)
|
||||||
|
el.height = Math.round(logH * scale)
|
||||||
|
if (gen === buildGen) {
|
||||||
|
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
||||||
|
if (isDev && mode === 'idle' && devMetrics) {
|
||||||
|
devMetrics = {
|
||||||
|
...devMetrics,
|
||||||
|
resizeCount: devMetrics.resizeCount + 1,
|
||||||
|
stampCount: stamps.length,
|
||||||
|
lastResizeAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraCleanup: (() => void) | undefined
|
||||||
|
|
||||||
|
if (isTauri) {
|
||||||
|
let tauriRo: ResizeObserver | undefined
|
||||||
|
let tauriUnlisten: (() => void) | undefined
|
||||||
|
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||||
|
const win = getCurrentWindow()
|
||||||
|
const doSync = () => Promise.all([win.innerSize(), win.scaleFactor()])
|
||||||
|
.then(([phys, scale]) => applySize(phys.width / scale, phys.height / scale, scale))
|
||||||
|
doSync()
|
||||||
|
tauriRo = new ResizeObserver(() => doSync())
|
||||||
|
tauriRo.observe(el)
|
||||||
|
win.onFocusChanged(() => doSync()).then(u => { tauriUnlisten = u })
|
||||||
|
})
|
||||||
|
extraCleanup = () => { tauriRo?.disconnect(); tauriUnlisten?.() }
|
||||||
|
} else {
|
||||||
|
const syncWeb = () => applySize(el.clientWidth, el.clientHeight, window.devicePixelRatio || 1)
|
||||||
|
const ro = new ResizeObserver(() => syncWeb())
|
||||||
|
ro.observe(el)
|
||||||
|
requestAnimationFrame(() => syncWeb())
|
||||||
|
extraCleanup = () => ro.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let raf = 0, t0 = -1, paused = false
|
||||||
|
|
||||||
function frame(now: number) {
|
function frame(now: number) {
|
||||||
if (paused) { raf = 0; return; }
|
if (paused) { raf = 0; return }
|
||||||
raf = requestAnimationFrame(frame);
|
raf = requestAnimationFrame(frame)
|
||||||
if (!live) return;
|
if (!live) return
|
||||||
if (t0 < 0) t0 = now;
|
const { cards, trigs, stamps, vignette, CW, CH, scale } = live
|
||||||
if (showFps) tickFps(now);
|
if (CW <= 0 || CH <= 0 || vignette.width <= 0 || vignette.height <= 0) return
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
if (stamps.some(s => s.width <= 0 || s.height <= 0)) return
|
||||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
if (t0 < 0) t0 = now
|
||||||
|
if (showFps) tickFps(now)
|
||||||
|
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pause() { paused = true; t0 = -1; }
|
function pause() { paused = true; t0 = -1 }
|
||||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame); }
|
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) }
|
||||||
|
function onVis() { document.hidden ? pause() : resume() }
|
||||||
|
|
||||||
function onVisibility() { document.hidden ? pause() : resume(); }
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", onVisibility);
|
return {
|
||||||
|
destroy() {
|
||||||
let unlistenFocus: Promise<() => void> | null = null;
|
cancelAnimationFrame(raf)
|
||||||
try {
|
cleanup()
|
||||||
const win = getCurrentWindow();
|
extraCleanup?.()
|
||||||
unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
focused ? resume() : pause();
|
if (isDev && mode === 'idle') {
|
||||||
});
|
splashDevUnregister(el)
|
||||||
} catch { }
|
devLiveCount = splashDevLiveCount()
|
||||||
|
}
|
||||||
raf = requestAnimationFrame(frame);
|
}
|
||||||
return () => {
|
}
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
ro.disconnect();
|
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
|
||||||
unlistenFocus?.then(f => f());
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
<div class="splash" class:exiting style="cursor:{mode === 'idle' ? 'pointer' : 'default'}">
|
||||||
{#if showCards}
|
{#if showCards}
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||||
{#if showFps}
|
{#if showFps}
|
||||||
@@ -386,26 +472,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === "idle" && lockEnabled}
|
{#if isDev && mode === 'idle' && devMetrics && showDevOverlay}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
<div class="dev-overlay">
|
||||||
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
<span class="dev-title">canvas · idle splash</span>
|
||||||
<div class="logo-glow"></div>
|
<div class="dev-grid">
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
<span class="dev-k">live</span> <span class="dev-v" class:dev-warn={devLiveCount > 1}>{devLiveCount}</span>
|
||||||
</div>
|
<span class="dev-k">total mounts</span> <span class="dev-v">{devMetrics.totalMounts}</span>
|
||||||
<div class="pin-card">
|
<span class="dev-k">stamps</span> <span class="dev-v">{devMetrics.stampCount}</span>
|
||||||
<p class="pin-label">Enter PIN</p>
|
<span class="dev-k">resizes</span> <span class="dev-v">{devMetrics.resizeCount}</span>
|
||||||
<div class="pin-block">
|
<span class="dev-k">uptime</span> <span class="dev-v">{fmtUptime(uptimeSecs)}</span>
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
<span class="dev-k">last resize</span> <span class="dev-v">{fmtAgo(devMetrics.lastResizeAt)}</span>
|
||||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{:else if mode === "idle"}
|
{#if mode === 'idle'}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
@@ -414,13 +495,24 @@
|
|||||||
<p class="hint">press any key to continue</p>
|
<p class="hint">press any key to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else if mode === 'locked'}
|
||||||
|
<div class="pin-card" class:pin-card--leaving={exiting}>
|
||||||
|
<div class="logo-wrap">
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:56px;height:56px;border-radius:14px;display:block;position:relative" />
|
||||||
|
</div>
|
||||||
|
<p class="pin-label">Enter PIN</p>
|
||||||
|
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||||
|
{#each Array(pinLen) as _, i}
|
||||||
|
<div class="pin-dot" class:filled={i < pinEntry.length}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if isTauri || failed || notConfigured || ringFull}
|
||||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||||
{#if !failed && !notConfigured}
|
{#if !failed && !notConfigured && isTauri}
|
||||||
<svg width={ringSize} height={ringSize}
|
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
|
||||||
class="loading-ring"
|
|
||||||
class:ring-hide={lockEnabled && pinVisible}
|
|
||||||
style="position:absolute;top:0;left:0;pointer-events:none">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -432,34 +524,16 @@
|
|||||||
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-area" style="z-index:1">
|
<div class="bottom-area" style="z-index:1">
|
||||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
|
||||||
{#if failed || notConfigured}
|
{#if failed || notConfigured}
|
||||||
<div class="error-box anim-fade-up">
|
<div class="error-box anim-fade-up">
|
||||||
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if lockEnabled}
|
|
||||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
|
||||||
<div class="pin-card">
|
|
||||||
<p class="pin-label">Enter PIN</p>
|
|
||||||
<div class="pin-block">
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -467,7 +541,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splash { position:fixed; inset:0; z-index:9999; background:var(--bg-base); overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; animation:spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
.splash { position:fixed; inset:0; z-index:9999; background:var(--bg-base); overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; animation:spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
.exiting { animation:spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||||
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||||
@@ -479,6 +553,24 @@
|
|||||||
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; }
|
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; }
|
||||||
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.logo-wrap { position:relative; width:72px; height:72px; display:flex; align-items:center; justify-content:center; }
|
||||||
|
|
||||||
|
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||||
|
.pin-card--leaving { animation:cardOut 0.28s cubic-bezier(0.4,0,1,1) both; }
|
||||||
|
|
||||||
|
.pin-label { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wider); text-transform:uppercase; margin:0; }
|
||||||
|
.pin-dots { display:flex; gap:12px; align-items:center; }
|
||||||
|
.pin-dot { width:10px; height:10px; border-radius:50%; border:1px solid var(--border-strong); background:transparent; transition:background 0.12s, border-color 0.12s; }
|
||||||
|
.pin-dot.filled { background:var(--accent); border-color:var(--accent); }
|
||||||
|
.pin-shake { animation:pinShake 0.42s ease; }
|
||||||
|
|
||||||
|
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||||
|
@keyframes cardOut { from { opacity:1; transform:translateY(0) scale(1) } to { opacity:0; transform:translateY(18px) scale(0.97) } }
|
||||||
|
|
||||||
|
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; }
|
||||||
|
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
|
||||||
|
.loading-ring { transition:opacity 0.5s ease; }
|
||||||
|
|
||||||
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
||||||
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
||||||
.error-actions { display:flex; gap:6px; }
|
.error-actions { display:flex; gap:6px; }
|
||||||
@@ -487,21 +579,10 @@
|
|||||||
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||||
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||||
|
|
||||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
.dev-overlay { position:absolute; top:12px; left:12px; z-index:10; background:rgba(0,0,0,0.72); border:1px solid rgba(255,255,255,0.10); border-radius:6px; padding:8px 10px; pointer-events:none; backdrop-filter:blur(6px); }
|
||||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
.dev-title { display:block; font-family:var(--font-ui); font-size:9px; letter-spacing:0.14em; text-transform:uppercase; color:var(--accent); margin-bottom:6px; }
|
||||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
.dev-grid { display:grid; grid-template-columns:auto auto; column-gap:12px; row-gap:2px; }
|
||||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
.dev-k { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); white-space:nowrap; }
|
||||||
.loading-ring { transition: opacity 0.5s ease; }
|
.dev-v { font-family:var(--font-ui); font-size:10px; color:var(--text-secondary); text-align:right; white-space:nowrap; }
|
||||||
.ring-hide { opacity: 0; }
|
.dev-warn { color:#f87171; }
|
||||||
|
|
||||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
|
||||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
||||||
.pin-card { background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
|
|
||||||
.pin-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin: 0; }
|
|
||||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
|
||||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
|
||||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
|
||||||
.pin-shake { animation: pinShake 0.42s ease; }
|
|
||||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -16,15 +16,22 @@
|
|||||||
let closeDialogOpen = $state(false)
|
let closeDialogOpen = $state(false)
|
||||||
let closeRemember = $state(false)
|
let closeRemember = $state(false)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
|
let unlistenResize: (() => void) | undefined
|
||||||
|
let unlistenClose: (() => void) | undefined
|
||||||
|
|
||||||
|
win.isFullscreen().then(v => { isFullscreen = v })
|
||||||
|
|
||||||
|
win.onResized(async () => {
|
||||||
isFullscreen = await win.isFullscreen()
|
isFullscreen = await win.isFullscreen()
|
||||||
const unlistenResize = await win.onResized(async () => {
|
}).then(u => { unlistenResize = u })
|
||||||
isFullscreen = await win.isFullscreen()
|
|
||||||
})
|
win.listen('tauri://close-requested', handleCloseRequested)
|
||||||
const unlistenClose = await win.listen('tauri://close-requested', handleCloseRequested)
|
.then(u => { unlistenClose = u })
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenResize()
|
unlistenResize?.()
|
||||||
unlistenClose()
|
unlistenClose?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,6 +63,10 @@
|
|||||||
if (choice === 'tray') await doHide()
|
if (choice === 'tray') await doHide()
|
||||||
else await doQuit()
|
else await doQuit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onBackdropKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { closeDialogOpen = false; closeRemember = false }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !isFullscreen}
|
{#if !isFullscreen}
|
||||||
@@ -99,8 +110,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if closeDialogOpen}
|
{#if closeDialogOpen}
|
||||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
<div
|
||||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
class="close-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={() => { closeDialogOpen = false; closeRemember = false }}
|
||||||
|
onkeydown={onBackdropKey}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="close-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Close Moku"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div class="close-header">
|
<div class="close-header">
|
||||||
<p class="close-title">Close Moku?</p>
|
<p class="close-title">Close Moku?</p>
|
||||||
<p class="close-sub">Choose how the app should exit.</p>
|
<p class="close-sub">Choose how the app should exit.</p>
|
||||||
@@ -169,6 +193,7 @@
|
|||||||
0 24px 64px rgba(0,0,0,0.7),
|
0 24px 64px rgba(0,0,0,0.7),
|
||||||
0 8px 24px rgba(0,0,0,0.4);
|
0 8px 24px rgba(0,0,0,0.4);
|
||||||
animation: cdPop 0.22s cubic-bezier(0.16,1,0.3,1) both;
|
animation: cdPop 0.22s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
@keyframes cdPop { from { opacity: 0; transform: scale(0.96) translateY(6px) } to { opacity: 1; transform: none } }
|
@keyframes cdPop { from { opacity: 0; transform: scale(0.96) translateY(6px) } to { opacity: 1; transform: none } }
|
||||||
|
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
const CARD_COUNT = 18
|
|
||||||
const CARD_W = 52
|
|
||||||
const CARD_H = 72
|
|
||||||
const CARD_RADIUS = 6
|
|
||||||
const DRIFT_SPEED = 0.018
|
|
||||||
|
|
||||||
interface Card {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
vx: number
|
|
||||||
vy: number
|
|
||||||
rot: number
|
|
||||||
vrot: number
|
|
||||||
opacity: number
|
|
||||||
scale: number
|
|
||||||
hue: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCard(w: number, h: number): Card {
|
|
||||||
const side = Math.floor(Math.random() * 4)
|
|
||||||
const margin = 80
|
|
||||||
let x = 0, y = 0
|
|
||||||
if (side === 0) { x = Math.random() * w; y = -margin }
|
|
||||||
if (side === 1) { x = w + margin; y = Math.random() * h }
|
|
||||||
if (side === 2) { x = Math.random() * w; y = h + margin }
|
|
||||||
if (side === 3) { x = -margin; y = Math.random() * h }
|
|
||||||
const cx = w / 2, cy = h / 2
|
|
||||||
const dx = cx - x, dy = cy - y
|
|
||||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
|
||||||
const spd = 0.12 + Math.random() * 0.1
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
|
|
||||||
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
|
|
||||||
rot: Math.random() * Math.PI * 2,
|
|
||||||
vrot: (Math.random() - 0.5) * 0.006,
|
|
||||||
opacity: 0.025 + Math.random() * 0.055,
|
|
||||||
scale: 0.7 + Math.random() * 0.7,
|
|
||||||
hue: 120 + Math.random() * 40,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
|
|
||||||
ctx.save()
|
|
||||||
ctx.globalAlpha = c.opacity
|
|
||||||
ctx.translate(c.x, c.y)
|
|
||||||
ctx.rotate(c.rot)
|
|
||||||
ctx.scale(c.scale, c.scale)
|
|
||||||
|
|
||||||
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
|
|
||||||
const x = -w / 2, y = -h / 2
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(x + r, y)
|
|
||||||
ctx.lineTo(x + w - r, y)
|
|
||||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
|
||||||
ctx.lineTo(x + w, y + h - r)
|
|
||||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
|
||||||
ctx.lineTo(x + r, y + h)
|
|
||||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
|
||||||
ctx.lineTo(x, y + r)
|
|
||||||
ctx.quadraticCurveTo(x, y, x + r, y)
|
|
||||||
ctx.closePath()
|
|
||||||
|
|
||||||
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
|
|
||||||
ctx.lineWidth = 1 / c.scale
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
const grad = ctx.createLinearGradient(x, y, x, y + h)
|
|
||||||
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
|
|
||||||
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
|
|
||||||
ctx.fillStyle = grad
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountCardCanvas(canvas: HTMLCanvasElement) {
|
|
||||||
const ctx = canvas.getContext('2d')!
|
|
||||||
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
|
|
||||||
let raf = 0
|
|
||||||
let running = true
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const dpr = window.devicePixelRatio || 1
|
|
||||||
canvas.width = canvas.offsetWidth * dpr
|
|
||||||
canvas.height = canvas.offsetHeight * dpr
|
|
||||||
ctx.scale(dpr, dpr)
|
|
||||||
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
if (!running) return
|
|
||||||
const w = canvas.offsetWidth, h = canvas.offsetHeight
|
|
||||||
ctx.clearRect(0, 0, w, h)
|
|
||||||
for (const c of cards) {
|
|
||||||
c.x += c.vx
|
|
||||||
c.y += c.vy
|
|
||||||
c.rot += c.vrot
|
|
||||||
const pad = 120
|
|
||||||
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
|
|
||||||
Object.assign(c, makeCard(w, h))
|
|
||||||
}
|
|
||||||
drawCard(ctx, c)
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(resize)
|
|
||||||
ro.observe(canvas)
|
|
||||||
resize()
|
|
||||||
tick()
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
running = false
|
|
||||||
cancelAnimationFrame(raf)
|
|
||||||
ro.disconnect()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ringGeometry(r: number, pad: number) {
|
|
||||||
const size = (r + pad) * 2
|
|
||||||
const c = size / 2
|
|
||||||
const circ = 2 * Math.PI * r
|
|
||||||
return { size, c, circ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const RING_STEPS = [
|
|
||||||
{ target: 0.15, duration: 400 },
|
|
||||||
{ target: 0.45, duration: 800 },
|
|
||||||
{ target: 0.72, duration: 600 },
|
|
||||||
{ target: 0.88, duration: 1000 },
|
|
||||||
{ target: 0.96, duration: 700 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function animateRingProgress(onProgress: (p: number) => void): () => void {
|
|
||||||
let current = 0.025
|
|
||||||
let stepIdx = 0
|
|
||||||
let start = performance.now()
|
|
||||||
let raf = 0
|
|
||||||
let stopped = false
|
|
||||||
|
|
||||||
function ease(t: number) {
|
|
||||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick(now: number) {
|
|
||||||
if (stopped) return
|
|
||||||
if (stepIdx >= RING_STEPS.length) return
|
|
||||||
|
|
||||||
const step = RING_STEPS[stepIdx]
|
|
||||||
const elapsed = now - start
|
|
||||||
const t = Math.min(elapsed / step.duration, 1)
|
|
||||||
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
|
|
||||||
current = from + (step.target - from) * ease(t)
|
|
||||||
onProgress(current)
|
|
||||||
|
|
||||||
if (t >= 1) {
|
|
||||||
stepIdx++
|
|
||||||
start = now
|
|
||||||
}
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(tick)
|
|
||||||
return () => { stopped = true; cancelAnimationFrame(raf) }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
import { CircleNotchIcon, ArrowClockwiseIcon, XIcon } from "phosphor-svelte";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import { longPress } from "$lib/core/ui/touchscreen";
|
import { longPress } from "$lib/core/ui/touchscreen";
|
||||||
import type { DownloadQueueItem } from "$lib/types/api";
|
import type { DownloadQueueItem } from "$lib/types/api";
|
||||||
@@ -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}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
|
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwiseIcon 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}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<XIcon size={12} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch } from "phosphor-svelte";
|
import { CircleNotchIcon } from "phosphor-svelte";
|
||||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||||
import type { DownloadQueueItem } from "$lib/types/api";
|
import type { DownloadQueueItem } from "$lib/types/api";
|
||||||
|
|
||||||
@@ -11,14 +11,12 @@
|
|||||||
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, onReorder, onReorderEdge, onSelect,
|
onRemove, onRetry, onSelect,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -54,8 +52,6 @@
|
|||||||
isSelected={selected.has(item.chapter.id)}
|
isSelected={selected.has(item.chapter.id)}
|
||||||
{onRemove}
|
{onRemove}
|
||||||
{onRetry}
|
{onRetry}
|
||||||
{onReorder}
|
|
||||||
{onReorderEdge}
|
|
||||||
{onSelect}
|
{onSelect}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -165,10 +165,8 @@
|
|||||||
isRunning={downloadStore.isRunning}
|
isRunning={downloadStore.isRunning}
|
||||||
dequeueing={downloadStore.dequeueing}
|
dequeueing={downloadStore.dequeueing}
|
||||||
selected={downloadStore.selected}
|
selected={downloadStore.selected}
|
||||||
onRemove={(id) => downloadStore.dequeue(id)}
|
onRemove={(id: number) => downloadStore.dequeue(id)}
|
||||||
onRetry={(id) => downloadStore.retryOne(id)}
|
onRetry={(id: number) => downloadStore.retryOne(id)}
|
||||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
|
||||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +217,7 @@
|
|||||||
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||||
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
|
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; appearance: textfield; }
|
||||||
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
.move-input:focus { color: var(--text-primary); }
|
.move-input:focus { color: var(--text-primary); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check, CircleNotch } from "phosphor-svelte";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
@@ -25,9 +25,19 @@
|
|||||||
|
|
||||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||||
|
|
||||||
|
const isLocal = $derived(pkgName === '__local__');
|
||||||
|
|
||||||
let groups: SourceLibrary[] = $state([]);
|
let groups: SourceLibrary[] = $state([]);
|
||||||
|
let sourceNodes: SourceNode[] = $state([]);
|
||||||
|
|
||||||
|
let localItems: any[] = $state([]);
|
||||||
|
let localPage: number = $state(1);
|
||||||
|
let localHasNext: boolean = $state(false);
|
||||||
|
let localLoadingMore: boolean = $state(false);
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
|
let searchInput = $state("");
|
||||||
|
|
||||||
type ContentFilter = "unread" | "downloaded";
|
type ContentFilter = "unread" | "downloaded";
|
||||||
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
||||||
@@ -36,36 +46,79 @@
|
|||||||
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
||||||
|
|
||||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||||
|
const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga));
|
||||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
const filtered = $derived((() => {
|
||||||
let items = allManga;
|
let items = allManga;
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
if (q && !isLocal) items = items.filter((m: any) => m.title.toLowerCase().includes(q));
|
||||||
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
|
if (!isLocal) {
|
||||||
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
|
if (activeFilters.unread) items = items.filter((m: any) => m.unreadCount > 0);
|
||||||
|
if (activeFilters.downloaded) items = items.filter((m: any) => m.downloadCount > 0);
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
})());
|
})());
|
||||||
|
|
||||||
let sourceNodes: SourceNode[] = $state([]);
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
$effect(() => { load(); });
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
if (isLocal) {
|
||||||
|
localPage = 1;
|
||||||
|
localItems = [];
|
||||||
|
const result = await getAdapter().browseSource('0', 1);
|
||||||
|
localItems = result.items;
|
||||||
|
localHasNext = result.hasNextPage;
|
||||||
|
localPage = 1;
|
||||||
|
} else {
|
||||||
const [libData, srcData] = await Promise.all([
|
const [libData, srcData] = await Promise.all([
|
||||||
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })),
|
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })),
|
||||||
getAdapter().getSources().then(nodes => ({ sources: { nodes } })),
|
getAdapter().getSources().then(nodes => ({ sources: { nodes } })),
|
||||||
]);
|
]);
|
||||||
sourceNodes = srcData.sources.nodes;
|
sourceNodes = srcData.sources.nodes;
|
||||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMoreLocal() {
|
||||||
|
if (localLoadingMore || !localHasNext) return;
|
||||||
|
localLoadingMore = true;
|
||||||
|
try {
|
||||||
|
const next = localPage + 1;
|
||||||
|
const result = await getAdapter().browseSource('0', next);
|
||||||
|
localItems = [...localItems, ...result.items];
|
||||||
|
localHasNext = result.hasNextPage;
|
||||||
|
localPage = next;
|
||||||
|
} finally {
|
||||||
|
localLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchLocal() {
|
||||||
|
const q = searchInput.trim();
|
||||||
|
if (!q) { load(); return; }
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const result = await getAdapter().searchSource('0', q, 1);
|
||||||
|
localItems = result.items;
|
||||||
|
localHasNext = result.hasNextPage;
|
||||||
|
localPage = 1;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
search = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isLocal) return;
|
||||||
|
if (e.key === 'Enter') searchLocal();
|
||||||
|
if (e.key === 'Escape') { searchInput = ''; search = ''; load(); }
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFilter(f: ContentFilter) {
|
function toggleFilter(f: ContentFilter) {
|
||||||
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||||
}
|
}
|
||||||
@@ -108,18 +161,31 @@
|
|||||||
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
<span class="eyebrow">In Library</span>
|
<span class="eyebrow">{isLocal ? 'Local Source' : 'In Library'}</span>
|
||||||
<span class="title">{extensionName}</span>
|
<span class="title">{extensionName}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
<span class="count-badge">
|
||||||
|
{isLocal ? allManga.length + (localHasNext ? '+' : '') : `${filtered.length}${filtered.length !== allManga.length ? ` / ${allManga.length}` : ''}`}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
|
{#if isLocal}
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
bind:value={searchInput}
|
||||||
|
autocomplete="off"
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !isLocal}
|
||||||
<div class="filter-wrap">
|
<div class="filter-wrap">
|
||||||
<button
|
<button
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
@@ -161,6 +227,7 @@
|
|||||||
<GearSix size={14} weight="bold" />
|
<GearSix size={14} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,10 +243,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if filtered.length === 0}
|
{:else if filtered.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
{isLocal
|
||||||
|
? 'No manga found in local source. Add manga folders to your local source directory.'
|
||||||
|
: allManga.length === 0
|
||||||
|
? 'Nothing from this extension is in your library.'
|
||||||
|
: 'No matches.'}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if groups.length > 1}
|
{#if !isLocal && groups.length > 1}
|
||||||
<div class="source-groups">
|
<div class="source-groups">
|
||||||
{#each groups as group}
|
{#each groups as group}
|
||||||
<div class="source-group-header">
|
<div class="source-group-header">
|
||||||
@@ -192,7 +263,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if groups.length === 1}
|
{:else if !isLocal && groups.length === 1}
|
||||||
<div class="single-source-bar">
|
<div class="single-source-bar">
|
||||||
<span class="source-group-name">{groups[0].displayName}</span>
|
<span class="source-group-name">{groups[0].displayName}</span>
|
||||||
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
||||||
@@ -214,6 +285,7 @@
|
|||||||
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
{#if !isLocal}
|
||||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||||
<div class="overlay-badges">
|
<div class="overlay-badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
@@ -226,11 +298,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isLocal && localHasNext}
|
||||||
|
<div class="load-more">
|
||||||
|
<button class="load-more-btn" onclick={loadMoreLocal} disabled={localLoadingMore}>
|
||||||
|
{#if localLoadingMore}
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
|
Loading…
|
||||||
|
{:else}
|
||||||
|
Load more
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,14 +409,19 @@
|
|||||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||||
|
|
||||||
.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; height: 2lh; }
|
.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; height: 2lh; }
|
||||||
.card.anims .card-title { transition: color var(--t-base); }
|
.card.anims .card-title { 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: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); text-align: center; padding: 0 var(--sp-6); }
|
||||||
|
|
||||||
|
.load-more { display: flex; justify-content: center; padding: var(--sp-4) 0; }
|
||||||
|
.load-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), background var(--t-base); }
|
||||||
|
.load-more-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
|
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
|
||||||
|
|
||||||
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
const anims = $derived(settingsState.settings.qolAnimations ?? true);
|
||||||
const cols = $derived(settingsState.settings.libraryCols ?? 5);
|
const cols = $derived(settingsState.settings.libraryPageSize ?? 5);
|
||||||
const cropCovers = $derived(settingsState.settings.cropCovers ?? true);
|
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true);
|
||||||
const statsAlways = $derived(settingsState.settings.statsAlways ?? false);
|
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false);
|
||||||
|
|
||||||
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 });
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let extensions: Extension[] = $state([]);
|
let extensions: Extension[] = $state([]);
|
||||||
let localMangaCount = $state(0);
|
let localMangaCount = $state<string>("0");
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let filter = $state<Filter>("installed");
|
let filter = $state<Filter>("installed");
|
||||||
@@ -84,8 +84,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLocalManga() {
|
async function loadLocalManga() {
|
||||||
const d = await Promise.resolve(null);
|
try {
|
||||||
|
const r = await getAdapter().browseSource('0', 1)
|
||||||
|
localMangaCount = r.hasNextPage ? r.items.length + '+' : String(r.items.length)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
async function fetchFromRepo() {
|
||||||
@@ -102,8 +104,7 @@
|
|||||||
async function loadRepos() {
|
async function loadRepos() {
|
||||||
reposLoading = true;
|
reposLoading = true;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ settings: { extensionRepos: string[] } }>(`query GetSettings { settings { extensionRepos } }`);
|
repos = await getAdapter().getExtensionRepos();
|
||||||
repos = d.settings.extensionRepos ?? [];
|
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
finally { reposLoading = false; }
|
finally { reposLoading = false; }
|
||||||
}
|
}
|
||||||
@@ -111,11 +112,11 @@
|
|||||||
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
||||||
savingRepos = true;
|
savingRepos = true;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(`mutation SetExtensionRepos($repos: [String!]!) { setSettings(input: { settings: { extensionRepos: $repos } }) { settings { extensionRepos } } }`, { repos: updated });
|
const removed = repos.find(r => !updated.includes(r)) ?? "";
|
||||||
repos = d.setSettings.settings.extensionRepos;
|
repos = await getAdapter().setExtensionRepos(updated);
|
||||||
addToast(intent === "add"
|
addToast(intent === "add"
|
||||||
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
||||||
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
|
: { kind: "info", title: "Repo removed", body: removed }
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
repoError = e instanceof Error ? e.message : "Failed to save";
|
repoError = e instanceof Error ? e.message : "Failed to save";
|
||||||
@@ -136,13 +137,11 @@
|
|||||||
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
||||||
working = new Set(working).add(pkgName);
|
working = new Set(working).add(pkgName);
|
||||||
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
||||||
const gqlArgs = {
|
|
||||||
install: { id: pkgName, install: true },
|
|
||||||
update: { id: pkgName, update: true },
|
|
||||||
uninstall: { id: pkgName, uninstall: true },
|
|
||||||
}[op];
|
|
||||||
try {
|
try {
|
||||||
await getAdapter()[{ install: 'installExtension', update: 'updateExtension', uninstall: 'uninstallExtension' }[op] as 'installExtension'](pkgName);
|
const adapter = getAdapter();
|
||||||
|
if (op === "install") await adapter.installExtension(pkgName);
|
||||||
|
else if (op === "update") await adapter.updateExtension(pkgName);
|
||||||
|
else await adapter.uninstallExtension(pkgName);
|
||||||
await load();
|
await load();
|
||||||
addToast({
|
addToast({
|
||||||
install: { kind: "download" as const, title: "Extension installed", body: label },
|
install: { kind: "download" as const, title: "Extension installed", body: label },
|
||||||
@@ -338,14 +337,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="list">
|
<div class="list">
|
||||||
{#if showLocal}
|
{#if showLocal}
|
||||||
<div class="local-row">
|
<button type="button" class="local-row" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}>
|
||||||
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">Local Source</span>
|
<span class="name">Local Source</span>
|
||||||
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
|
<span class="meta">Built-in · {localMangaCount} {localMangaCount === "1" ? "manga" : "mangas"}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="local-badge">Built-in</span>
|
<span class="local-badge">Built-in</span>
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each groups as { base, primary, variants }}
|
{#each groups as { base, primary, variants }}
|
||||||
<ExtensionCard
|
<ExtensionCard
|
||||||
@@ -405,8 +404,7 @@
|
|||||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||||
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
.local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; }
|
.local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; width: 100%; text-align: left; background: none; font: inherit; cursor: pointer; }
|
||||||
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export interface LibraryManga {
|
|||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
source: { id: string; displayName: string };
|
source: { id: string; displayName: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceLibrary {
|
export interface SourceLibrary {
|
||||||
@@ -17,7 +17,7 @@ export type SourceNode = {
|
|||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
isConfigurable: boolean;
|
isConfigurable: boolean;
|
||||||
extension: { pkgName: string };
|
extension?: { pkgName: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function libraryByExtension(
|
export function libraryByExtension(
|
||||||
@@ -31,7 +31,7 @@ export function libraryByExtension(
|
|||||||
const bySource = new Map<string, LibraryManga[]>();
|
const bySource = new Map<string, LibraryManga[]>();
|
||||||
for (const src of pkgSources) bySource.set(src.id, []);
|
for (const src of pkgSources) bySource.set(src.id, []);
|
||||||
for (const m of libraryManga) {
|
for (const m of libraryManga) {
|
||||||
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
if (m.source && sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pkgSources
|
return pkgSources
|
||||||
@@ -49,6 +49,7 @@ export function libraryCountByPkg(
|
|||||||
}
|
}
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const m of libraryManga) {
|
for (const m of libraryManga) {
|
||||||
|
if (!m.source) continue;
|
||||||
const pkg = sourceIdToPkg.get(m.source.id);
|
const pkg = sourceIdToPkg.get(m.source.id);
|
||||||
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,17 +66,7 @@
|
|||||||
editKey = null;
|
editKey = null;
|
||||||
listOpen = null;
|
listOpen = null;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
prefs = (await getAdapter().getSourceSettings(src.id)) as Preference[];
|
||||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { 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 }
|
|
||||||
} } }`,
|
|
||||||
{ id: String(src.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,24 +76,9 @@
|
|||||||
|
|
||||||
async function save(position: number, changeType: string, value: unknown) {
|
async function save(position: number, changeType: string, value: unknown) {
|
||||||
if (!activeSource) return;
|
if (!activeSource) return;
|
||||||
const pref = prefs[position];
|
saving = prefs[position].key;
|
||||||
saving = pref.key;
|
|
||||||
try {
|
try {
|
||||||
await (getAdapter() as any).gql(
|
prefs = (await getAdapter().updateSourcePreference(activeSource.id, position, changeType, value)) as Preference[];
|
||||||
`mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { updateSourcePreference(input: { source: $source, change: $change }) { source { id } } }`,
|
|
||||||
{ source: String(activeSource.id), change: { position, [changeType]: value } },
|
|
||||||
);
|
|
||||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
|
||||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
|
|
||||||
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxCurrentValue: currentValue key }
|
|
||||||
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceCurrentValue: currentValue key }
|
|
||||||
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceCurrentValue: currentValue entries entryValues key }
|
|
||||||
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceCurrentValue: currentValue key }
|
|
||||||
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
|
|
||||||
} } }`,
|
|
||||||
{ id: String(activeSource.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -112,12 +112,10 @@
|
|||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
entries[i] = { ...entries[i], status: "searching" };
|
entries[i] = { ...entries[i], status: "searching" };
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
const mangas = await getAdapter().searchManga(entries[i].manga.title, target.id);
|
||||||
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
|
const results = mangas
|
||||||
});
|
.map((m: Manga) => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
||||||
const results = d.fetchSourceManga.mangas
|
.sort((a: { manga: Manga; similarity: number }, b: { manga: Manga; similarity: number }) => b.similarity - a.similarity);
|
||||||
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
|
||||||
.sort((a, b) => b.similarity - a.similarity);
|
|
||||||
|
|
||||||
if (results.length > 0 && results[0].similarity > 0.3) {
|
if (results.length > 0 && results[0].similarity > 0.3) {
|
||||||
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
|
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
|
||||||
@@ -147,17 +145,15 @@
|
|||||||
for (const entry of toMigrate) {
|
for (const entry of toMigrate) {
|
||||||
const idx = entries.indexOf(entry);
|
const idx = entries.indexOf(entry);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
|
const newChaps = await getAdapter().fetchChapters(String(entry.match!.id));
|
||||||
const newChaps = d.fetchChapters.chapters;
|
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
const toMarkRead: number[] = [];
|
||||||
const toMarkBookmarked: number[] = [];
|
|
||||||
|
|
||||||
for (const nc of newChaps) {
|
// LibraryManga has no chapter detail — use unreadCount as a proxy:
|
||||||
const oldIdx = entries[idx].manga;
|
// if unreadCount < total fetched, the user had read some, so carry them all over.
|
||||||
if (oldIdx) {
|
const hadReads = entries[idx].manga.unreadCount < newChaps.length;
|
||||||
toMarkRead.push(nc.id);
|
if (hadReads) {
|
||||||
}
|
for (const nc of newChaps) toMarkRead.push(nc.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toMarkRead.length)
|
if (toMarkRead.length)
|
||||||
@@ -183,7 +179,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
|
<div class="overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }} onkeydown={(e) => { if (e.key === "Escape" && phase !== "migrating") onClose(); }}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
const entries = $derived(
|
const entries = $derived(
|
||||||
historyState.sessions
|
historyState.sessions
|
||||||
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
||||||
.slice(0, 10)
|
.slice(0, 6)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import { getAdapter } from '$lib/request-manager'
|
||||||
import { libraryState } from '$lib/state/library.svelte'
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
|
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
|
||||||
|
import { openReaderForChapter } from '$lib/state/series.svelte'
|
||||||
import { historyState } from '$lib/state/history.svelte'
|
import { historyState } from '$lib/state/history.svelte'
|
||||||
import type { ReadSession } from '$lib/types/history'
|
import type { ReadSession } from '$lib/types/history'
|
||||||
import HeroStage from '$lib/components/home/HeroStage.svelte'
|
import HeroStage from '$lib/components/home/HeroStage.svelte'
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
heroAllChapters = all
|
heroAllChapters = all
|
||||||
const lastReadIdx = heroEntry
|
const lastReadIdx = heroEntry
|
||||||
? all.findLastIndex(c => c.id === heroEntry!.endChapterId)
|
? all.findLastIndex(c => c.id === heroEntry!.endChapterId)
|
||||||
: all.findLastIndex(c => c.isRead)
|
: all.findLastIndex(c => c.read)
|
||||||
const startIdx = Math.max(0, lastReadIdx)
|
const startIdx = Math.max(0, lastReadIdx)
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5)
|
heroChapters = all.slice(startIdx, startIdx + 5)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
|
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
|
||||||
if (!heroEntry) return
|
if (!heroEntry) return
|
||||||
const target = heroAllChapters.find(c => c.id === heroEntry!.endChapterId) ?? heroAllChapters[0]
|
const target = heroAllChapters.find(c => c.id === heroEntry!.endChapterId) ?? heroAllChapters[0]
|
||||||
if (target) openChapter(target)
|
if (target) openReaderForChapter(target, heroManga ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface RecommendedManga {
|
|||||||
|
|
||||||
const TOP_GENRES = 6
|
const TOP_GENRES = 6
|
||||||
const TARGET_PER_GENRE = 20
|
const TARGET_PER_GENRE = 20
|
||||||
|
const FALLBACK_GENRES = ['Action', 'Adventure', 'Fantasy', 'Romance', 'Comedy', 'Drama']
|
||||||
|
|
||||||
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
||||||
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
||||||
@@ -16,8 +17,8 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
|||||||
|
|
||||||
for (const session of history) {
|
for (const session of history) {
|
||||||
const manga = byId.get(session.mangaId)
|
const manga = byId.get(session.mangaId)
|
||||||
if (!manga?.genre?.length) continue
|
if (!manga?.tags?.length) continue
|
||||||
for (const g of manga.genre) {
|
for (const g of manga.tags) {
|
||||||
const key = g.toLowerCase()
|
const key = g.toLowerCase()
|
||||||
const existing = tally.get(key)
|
const existing = tally.get(key)
|
||||||
if (existing) existing.count++
|
if (existing) existing.count++
|
||||||
@@ -25,10 +26,12 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...tally.values()]
|
const derived = [...tally.values()]
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, TOP_GENRES)
|
.slice(0, TOP_GENRES)
|
||||||
.map(e => e.original)
|
.map(e => e.original)
|
||||||
|
|
||||||
|
return derived.length > 0 ? derived : FALLBACK_GENRES
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecommendations(
|
export async function fetchRecommendations(
|
||||||
@@ -36,20 +39,22 @@ export async function fetchRecommendations(
|
|||||||
libraryManga: Manga[],
|
libraryManga: Manga[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<RecommendedManga[]> {
|
): Promise<RecommendedManga[]> {
|
||||||
if (!history.length || !libraryManga.length) return []
|
|
||||||
|
|
||||||
const genres = topGenres(history, libraryManga)
|
const genres = topGenres(history, libraryManga)
|
||||||
if (!genres.length) return []
|
|
||||||
|
|
||||||
const adapter = getAdapter()
|
const adapter = getAdapter() as any
|
||||||
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
||||||
|
|
||||||
const perGenre = await Promise.all(
|
const perGenre = await Promise.all(
|
||||||
genres.map(async genre => {
|
genres.map(async genre => {
|
||||||
if (signal?.aborted) return []
|
if (signal?.aborted) return []
|
||||||
try {
|
try {
|
||||||
const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false })
|
const { items } = await adapter.getMangasByGenre(
|
||||||
return items
|
{ genre: { like: `%${genre}%` } },
|
||||||
|
TARGET_PER_GENRE,
|
||||||
|
0,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
return items as Manga[]
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -57,19 +62,21 @@ export async function fetchRecommendations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const merged: Manga[] = []
|
const merged: Manga[] = []
|
||||||
for (const items of perGenre) {
|
outer: for (const items of perGenre) {
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
|
if (signal?.aborted) break outer
|
||||||
if (globalSeen.has(m.id)) continue
|
if (globalSeen.has(m.id)) continue
|
||||||
globalSeen.add(m.id)
|
globalSeen.add(m.id)
|
||||||
merged.push(m)
|
merged.push(m)
|
||||||
if (merged.length >= genres.length * TARGET_PER_GENRE) break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.map(m => ({
|
return merged.map(m => {
|
||||||
|
const mTagsLower = (m.tags ?? []).map(g => g.toLowerCase())
|
||||||
|
const matched = genres.filter(g => mTagsLower.includes(g.toLowerCase()))
|
||||||
|
return {
|
||||||
manga: m,
|
manga: m,
|
||||||
matchedGenres: (m.genre ?? []).filter(g =>
|
matchedGenres: matched.length > 0 ? matched : [genres[0]],
|
||||||
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
}
|
||||||
),
|
})
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import { getAdapter } from '$lib/request-manager'
|
||||||
import { libraryState } from '$lib/state/library.svelte'
|
import { libraryState } from '$lib/state/library.svelte'
|
||||||
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
|
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
|
||||||
import { startLibraryUpdate } from '$lib/components/library/lib/libraryUpdater'
|
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
||||||
|
import { readerState } from '$lib/state/reader.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
|
||||||
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
||||||
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
||||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||||
@@ -16,6 +15,7 @@
|
|||||||
Books, Folder, FolderSimple, FolderSimplePlus,
|
Books, Folder, FolderSimple, FolderSimplePlus,
|
||||||
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
|
import { openMangaFolder, openDownloadsFolder } from '$lib/core/filesystem'
|
||||||
|
|
||||||
const SIDEBAR_W = 52
|
const SIDEBAR_W = 52
|
||||||
const TITLEBAR_H = 36
|
const TITLEBAR_H = 36
|
||||||
@@ -23,13 +23,17 @@
|
|||||||
const DT_TAB = 'application/x-moku-tab'
|
const DT_TAB = 'application/x-moku-tab'
|
||||||
const COMPLETED_NAME = 'Completed'
|
const COMPLETED_NAME = 'Completed'
|
||||||
|
|
||||||
let cancelUpdate: (() => void) | null = null
|
let statusPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
|
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const UPDATE_STATUS_POLL_MS = 2_000
|
||||||
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null)
|
let emptyCtx: { x: number; y: number } | null = $state(null)
|
||||||
|
|
||||||
let bulkWorking: boolean = $state(false)
|
let bulkWorking: boolean = $state(false)
|
||||||
|
let sortPanelOpen: boolean = $state(false)
|
||||||
|
let filterPanelOpen: boolean = $state(false)
|
||||||
let activeDragKind: 'tab' | null = $state(null)
|
let activeDragKind: 'tab' | null = $state(null)
|
||||||
let dragInsertIdx = $state(-1)
|
let dragInsertIdx = $state(-1)
|
||||||
let dragTabId: string|null = $state(null)
|
let dragTabId: string|null = $state(null)
|
||||||
@@ -42,6 +46,9 @@
|
|||||||
$effect(() => { libraryState.syncFromSettings(settingsState.settings) })
|
$effect(() => { libraryState.syncFromSettings(settingsState.settings) })
|
||||||
$effect(() => { libraryState.tab; libraryState.exitSelect() })
|
$effect(() => { libraryState.tab; libraryState.exitSelect() })
|
||||||
$effect(() => { libraryState.guardTab() })
|
$effect(() => { libraryState.guardTab() })
|
||||||
|
$effect(() => {
|
||||||
|
if (readerState.activeManga === null) loadLibrary()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
libraryState.loading = true
|
libraryState.loading = true
|
||||||
@@ -115,26 +122,6 @@
|
|||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMangaFolder(m: Manga) {
|
|
||||||
let base: string | undefined
|
|
||||||
try { base = await invoke<string>('get_default_downloads_path') } catch {}
|
|
||||||
if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
|
||||||
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
|
|
||||||
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
|
|
||||||
const path = source
|
|
||||||
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
|
|
||||||
: `${base}/mangas/${sanitize(m.title)}`
|
|
||||||
try { await invoke('open_path', { path }) }
|
|
||||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDownloadsFolder() {
|
|
||||||
let path: string | undefined
|
|
||||||
try { path = await invoke<string>('get_default_downloads_path') } catch {}
|
|
||||||
if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
|
||||||
try { await invoke('open_path', { path }) }
|
|
||||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSingleManga(m: Manga) {
|
async function refreshSingleManga(m: Manga) {
|
||||||
if (libraryState.refreshingMangaId !== null) return
|
if (libraryState.refreshingMangaId !== null) return
|
||||||
@@ -217,33 +204,57 @@
|
|||||||
} finally { bulkWorking = false }
|
} finally { bulkWorking = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopStatusPolling() {
|
||||||
|
if (!statusPollTimer) return
|
||||||
|
clearTimeout(statusPollTimer)
|
||||||
|
statusPollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
async function startRefresh() {
|
async function startRefresh() {
|
||||||
if (libraryState.refreshing) return
|
if (libraryState.refreshing) return
|
||||||
libraryState.refreshing = true
|
libraryState.refreshing = true
|
||||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||||
|
|
||||||
cancelUpdate = startLibraryUpdate({
|
try {
|
||||||
onProgress(p) { libraryState.refreshProgress = p },
|
await getAdapter().checkForUpdates()
|
||||||
async onDone({ newChapters, totalUpdated }) {
|
} catch (e) {
|
||||||
cancelUpdate = null
|
libraryState.refreshing = false
|
||||||
await loadLibrary()
|
addToast({ kind: 'error', title: 'Update failed', body: String(e) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
statusPollTimer = null
|
||||||
|
try {
|
||||||
|
const statusRes = await getAdapter().getLibraryUpdateStatus()
|
||||||
|
const wasRunning = libraryState.refreshing
|
||||||
|
|
||||||
|
libraryState.refreshProgress = {
|
||||||
|
finished: statusRes.finishedJobs ?? 0,
|
||||||
|
total: statusRes.totalJobs ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusRes.isRunning) {
|
||||||
|
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
|
} else if (wasRunning) {
|
||||||
libraryState.refreshing = false
|
libraryState.refreshing = false
|
||||||
libraryState.refreshDone = true
|
libraryState.refreshDone = true
|
||||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
|
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
|
||||||
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
|
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
|
||||||
if (newChapters > 0) {
|
await loadLibrary()
|
||||||
addToast({ kind: 'success', title: 'Library updated', body: `${newChapters} new chapter${newChapters !== 1 ? 's' : ''} across ${totalUpdated} series` })
|
addToast({ kind: 'info', title: 'Library updated' })
|
||||||
} else {
|
|
||||||
addToast({ kind: 'info', title: 'Already up to date' })
|
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
onError() { libraryState.refreshing = false; cancelUpdate = null },
|
if (libraryState.refreshing) statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelRefresh() {
|
async function cancelRefresh() {
|
||||||
if (!libraryState.refreshing) return
|
if (!libraryState.refreshing) return
|
||||||
cancelUpdate?.(); cancelUpdate = null
|
stopStatusPolling()
|
||||||
try { await getAdapter().stopLibraryUpdate() } catch {}
|
try { await getAdapter().stopLibraryUpdate() } catch {}
|
||||||
libraryState.refreshing = false
|
libraryState.refreshing = false
|
||||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||||
@@ -382,7 +393,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<LibraryToolbar
|
<LibraryToolbar
|
||||||
tab={libraryState.tab}
|
tab={libraryState.tab}
|
||||||
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
|
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'az'}
|
||||||
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
|
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
|
||||||
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
|
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
|
||||||
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
|
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
|
||||||
@@ -390,7 +401,7 @@
|
|||||||
visibleCategories={libraryState.visibleCategories}
|
visibleCategories={libraryState.visibleCategories}
|
||||||
visibleTabIds={libraryState.visibleTabIds}
|
visibleTabIds={libraryState.visibleTabIds}
|
||||||
counts={libraryState.counts}
|
counts={libraryState.counts}
|
||||||
query={libraryState.filter.query}
|
search={libraryState.filter.query}
|
||||||
refreshing={libraryState.refreshing}
|
refreshing={libraryState.refreshing}
|
||||||
refreshProgress={libraryState.refreshProgress}
|
refreshProgress={libraryState.refreshProgress}
|
||||||
refreshDone={libraryState.refreshDone}
|
refreshDone={libraryState.refreshDone}
|
||||||
@@ -399,16 +410,19 @@
|
|||||||
{dragInsertIdx}
|
{dragInsertIdx}
|
||||||
{dragTabId}
|
{dragTabId}
|
||||||
{dragOverTabId}
|
{dragOverTabId}
|
||||||
|
{sortPanelOpen}
|
||||||
|
{filterPanelOpen}
|
||||||
onTabChange={(t) => libraryState.tab = t}
|
onTabChange={(t) => libraryState.tab = t}
|
||||||
onQuery={(q) => libraryState.filter.query = q}
|
onSearchChange={(q) => libraryState.filter.query = q}
|
||||||
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
|
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
|
||||||
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
|
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
|
||||||
|
onSortPanelToggle={() => sortPanelOpen = !sortPanelOpen}
|
||||||
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
|
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
|
||||||
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
|
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
|
||||||
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
|
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
|
||||||
|
onFilterPanelToggle={() => filterPanelOpen = !filterPanelOpen}
|
||||||
onRefresh={startRefresh}
|
onRefresh={startRefresh}
|
||||||
onCancelRefresh={cancelRefresh}
|
onCancelRefresh={cancelRefresh}
|
||||||
onRefreshCategory={refreshCategory}
|
|
||||||
onOpenDownloadsFolder={openDownloadsFolder}
|
onOpenDownloadsFolder={openDownloadsFolder}
|
||||||
onTabDragStart={onTabDragStart}
|
onTabDragStart={onTabDragStart}
|
||||||
onTabDragOver={onTabDragOver}
|
onTabDragOver={onTabDragOver}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import type { Manga, Category } from '$lib/types'
|
import type { Manga, Category } from '$lib/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,14 +26,10 @@
|
|||||||
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
const THUMB_BASE = 'http://127.0.0.1:4567'
|
|
||||||
|
|
||||||
let movePanelOpen = $state(false)
|
let movePanelOpen = $state(false)
|
||||||
|
|
||||||
function coverUrl(m: Manga) {
|
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
|
||||||
const url = m.thumbnailUrl ?? ''
|
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
|
||||||
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDocDown(e: MouseEvent) {
|
function onDocDown(e: MouseEvent) {
|
||||||
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
||||||
@@ -120,17 +118,12 @@
|
|||||||
class="card"
|
class="card"
|
||||||
class:card-selected={isSelected}
|
class:card-selected={isSelected}
|
||||||
class:select-mode={selectMode}
|
class:select-mode={selectMode}
|
||||||
|
class:stats-always={statsAlways}
|
||||||
onclick={(e) => onCardClick(e, m)}
|
onclick={(e) => onCardClick(e, m)}
|
||||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted} class:cover-contain={!cropCovers}>
|
||||||
<img
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
|
||||||
class="cover"
|
|
||||||
src={coverUrl(m)}
|
|
||||||
alt={m.title}
|
|
||||||
draggable="false"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
@@ -247,7 +240,8 @@
|
|||||||
}
|
}
|
||||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||||
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.cover-contain :global(.cover) { object-fit: contain; }
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||||
@@ -257,6 +251,7 @@
|
|||||||
transition: opacity 0.18s ease;
|
transition: opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
.card:not(.select-mode):hover .overlay { opacity: 1; }
|
.card:not(.select-mode):hover .overlay { opacity: 1; }
|
||||||
|
.stats-always .overlay { opacity: 1; }
|
||||||
|
|
||||||
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||||
.badge {
|
.badge {
|
||||||
|
|||||||
@@ -4,21 +4,19 @@
|
|||||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "$lib/types";
|
||||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tab: string;
|
tab: string;
|
||||||
tabSortMode: LibrarySortMode;
|
tabSortMode: LibrarySortOption;
|
||||||
tabSortDir: LibrarySortDir;
|
tabSortDir: LibrarySortDir;
|
||||||
tabStatus: LibraryStatusFilter;
|
tabStatus: LibraryStatusFilter;
|
||||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||||
hasActiveFilters: boolean;
|
hasActiveFilters: boolean;
|
||||||
anims: boolean;
|
anims?: boolean;
|
||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
visibleTabIds: string[];
|
visibleTabIds: string[];
|
||||||
virtualTabIds: string[];
|
|
||||||
folderTabIds: string[];
|
|
||||||
completedCatId: number | null;
|
completedCatId: number | null;
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -35,7 +33,7 @@
|
|||||||
tabsEl: HTMLDivElement;
|
tabsEl: HTMLDivElement;
|
||||||
onSearchChange: (v: string) => void;
|
onSearchChange: (v: string) => void;
|
||||||
onTabChange: (f: string) => void;
|
onTabChange: (f: string) => void;
|
||||||
onSortChange: (mode: LibrarySortMode) => void;
|
onSortChange: (mode: LibrarySortOption) => void;
|
||||||
onSortDirToggle: () => void;
|
onSortDirToggle: () => void;
|
||||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
onStatusChange: (s: LibraryStatusFilter) => void;
|
||||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
onFilterToggle: (f: LibraryContentFilter) => void;
|
||||||
@@ -44,7 +42,6 @@
|
|||||||
onFilterPanelToggle: () => void;
|
onFilterPanelToggle: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCancelRefresh: () => void;
|
onCancelRefresh: () => void;
|
||||||
onRefreshCategory: (catId: number) => void;
|
|
||||||
onOpenDownloadsFolder: () => void;
|
onOpenDownloadsFolder: () => void;
|
||||||
onTabDragStart: (e: DragEvent, id: string) => void;
|
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||||
@@ -55,13 +52,13 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||||
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
anims = false, visibleCategories, visibleTabIds, completedCatId,
|
||||||
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
|
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
|
||||||
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||||
tabsEl = $bindable(),
|
tabsEl = $bindable(),
|
||||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||||
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
|
onRefresh, onCancelRefresh, onOpenDownloadsFolder,
|
||||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -85,18 +82,18 @@
|
|||||||
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
const SORT_LABELS: Record<LibrarySortOption, string> = {
|
||||||
az: "A–Z",
|
az: "A–Z",
|
||||||
unreadCount: "Unread chapters",
|
unreadCount: "Unread chapters",
|
||||||
totalChapters: "Total chapters",
|
totalChapters: "Total chapters",
|
||||||
recentlyAdded: "Recently added",
|
dateAdded: "Recently added",
|
||||||
recentlyRead: "Recently read",
|
lastRead: "Recently read",
|
||||||
latestFetched: "Latest fetched chapter",
|
latestFetched: "Latest fetched chapter",
|
||||||
latestUploaded: "Latest uploaded chapter",
|
latestUploaded: "Latest uploaded chapter",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
const ALL_SORT_MODES: LibrarySortOption[] = [
|
||||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
"az", "unreadCount", "totalChapters", "dateAdded", "lastRead", "latestFetched", "latestUploaded",
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -212,14 +209,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LibraryFilters
|
<LibraryFilters
|
||||||
{tabStatus}
|
status={tabStatus}
|
||||||
{tabFilters}
|
filters={tabFilters}
|
||||||
{hasActiveFilters}
|
hasActive={hasActiveFilters}
|
||||||
{filterPanelOpen}
|
open={filterPanelOpen}
|
||||||
|
onToggle={onFilterPanelToggle}
|
||||||
{onStatusChange}
|
{onStatusChange}
|
||||||
{onFilterToggle}
|
{onFilterToggle}
|
||||||
{onFiltersClear}
|
onClear={onFiltersClear}
|
||||||
{onFilterPanelToggle}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2000
|
|
||||||
const POLL_INITIAL_MS = 500
|
|
||||||
|
|
||||||
export interface UpdateProgress {
|
|
||||||
finished: number
|
|
||||||
total: number
|
|
||||||
skippedManga: number
|
|
||||||
skippedCategories: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateResult {
|
|
||||||
entries: UpdateEntry[]
|
|
||||||
totalUpdated: number
|
|
||||||
newChapters: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateEntry {
|
|
||||||
mangaId: number
|
|
||||||
mangaTitle: string
|
|
||||||
thumbnailUrl: string
|
|
||||||
newChapters: number
|
|
||||||
checkedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LibraryUpdaterCallbacks {
|
|
||||||
onProgress: (p: UpdateProgress) => void
|
|
||||||
onDone: (r: UpdateResult) => void
|
|
||||||
onError: (e?: unknown) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEntries(
|
|
||||||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
|
|
||||||
): UpdateEntry[] {
|
|
||||||
const byManga = new Map<number, UpdateEntry>()
|
|
||||||
for (const u of mangaUpdates) {
|
|
||||||
if (u.status !== 'UPDATED') continue
|
|
||||||
const existing = byManga.get(u.manga.id)
|
|
||||||
if (existing) {
|
|
||||||
existing.newChapters++
|
|
||||||
} else {
|
|
||||||
byManga.set(u.manga.id, {
|
|
||||||
mangaId: u.manga.id,
|
|
||||||
mangaTitle: u.manga.title,
|
|
||||||
thumbnailUrl: u.manga.thumbnailUrl,
|
|
||||||
newChapters: 1,
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...byManga.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
cancelled = true
|
|
||||||
if (timer) { clearTimeout(timer); timer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
let jobsStarted = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await getAdapter().checkForUpdates()
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
const { jobsInfo } = status
|
|
||||||
jobsStarted = jobsInfo.totalJobs > 0
|
|
||||||
|
|
||||||
callbacks.onProgress({
|
|
||||||
finished: jobsInfo.finishedJobs,
|
|
||||||
total: jobsInfo.totalJobs,
|
|
||||||
skippedManga: jobsInfo.skippedMangasCount,
|
|
||||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!jobsStarted || !jobsInfo.isRunning) {
|
|
||||||
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[libraryUpdater] failed to start update', e)
|
|
||||||
if (!cancelled) callbacks.onError(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function poll() {
|
|
||||||
getAdapter().getLibraryUpdateStatus()
|
|
||||||
.then(d => {
|
|
||||||
if (cancelled) return
|
|
||||||
const { jobsInfo, mangaUpdates } = d
|
|
||||||
|
|
||||||
if (jobsInfo.totalJobs > 0) jobsStarted = true
|
|
||||||
callbacks.onProgress({
|
|
||||||
finished: jobsInfo.finishedJobs,
|
|
||||||
total: jobsInfo.totalJobs,
|
|
||||||
skippedManga: jobsInfo.skippedMangasCount,
|
|
||||||
skippedCategories: jobsInfo.skippedCategoriesCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!jobsInfo.isRunning && jobsStarted) {
|
|
||||||
const entries = buildEntries(mangaUpdates)
|
|
||||||
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0)
|
|
||||||
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(poll, POLL_INTERVAL_MS)
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error('[libraryUpdater] poll error', e)
|
|
||||||
if (!cancelled) callbacks.onError(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(poll, POLL_INITIAL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
run()
|
|
||||||
return cancel
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
|
||||||
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
import type { StripChapter } from "$lib/components/reader/lib/scrollHandler";
|
import { READ_LINE_PCT } from "$lib/components/reader/lib/scrollHandler";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import LongstripViewer from "$lib/components/reader/viewer/LongstripViewer.svelte";
|
||||||
|
import SingleViewer from "$lib/components/reader/viewer/SingleViewer.svelte";
|
||||||
|
import DoubleViewer from "$lib/components/reader/viewer/DoubleViewer.svelte";
|
||||||
|
|
||||||
|
export interface StripChapter {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatPage = {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
localIndex: number;
|
||||||
|
url: string;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: string;
|
style: string;
|
||||||
@@ -14,10 +31,10 @@
|
|||||||
pageReady: boolean;
|
pageReady: boolean;
|
||||||
pageGroups: number[][];
|
pageGroups: number[][];
|
||||||
currentGroup: number[];
|
currentGroup: number[];
|
||||||
stripToRender: StripChapter[];
|
|
||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
pinchZoomEnabled: boolean;
|
pinchZoomEnabled: boolean;
|
||||||
|
useBlob: boolean;
|
||||||
barPosition: "top" | "left" | "right";
|
barPosition: "top" | "left" | "right";
|
||||||
onGetZoom: () => number;
|
onGetZoom: () => number;
|
||||||
onSetZoom: (z: number) => void;
|
onSetZoom: (z: number) => void;
|
||||||
@@ -26,139 +43,151 @@
|
|||||||
onWheel: (e: WheelEvent) => void;
|
onWheel: (e: WheelEvent) => void;
|
||||||
onToggleUi: () => void;
|
onToggleUi: () => void;
|
||||||
bindContainer: (el: HTMLDivElement) => void;
|
bindContainer: (el: HTMLDivElement) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onChapterChange: (chapterId: number) => void;
|
||||||
|
onCenterIdxChange:(flatIdx: number) => void;
|
||||||
|
onMarkRead: (chapterId: number) => void;
|
||||||
|
onAppend: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, fadingOut,
|
||||||
tapToToggleBar, pinchZoomEnabled, barPosition,
|
tapToToggleBar, pinchZoomEnabled, useBlob, barPosition,
|
||||||
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
|
onPageChange, onChapterChange, onCenterIdxChange, onMarkRead, onAppend,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const LOAD_RADIUS = 5;
|
let stripChunks = $state<StripChapter[]>([]);
|
||||||
const UNLOAD_RADIUS = 10;
|
|
||||||
|
|
||||||
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
export function loadStrip(chapterId: number, chapterName: string, urls: string[], resumeTo = 0) {
|
||||||
|
stripChunks = [{ chapterId, chapterName, urls }];
|
||||||
|
if (resumeTo > 1) {
|
||||||
|
setTimeout(() => scrollToFlatIndex(resumeTo - 1), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendStripChunk(chapterId: number, chapterName: string, urls: string[]) {
|
||||||
|
if (stripChunks.some(c => c.chapterId === chapterId)) return;
|
||||||
|
stripChunks = [...stripChunks, { chapterId, chapterName, urls }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStripChunks(): StripChapter[] {
|
||||||
|
return stripChunks;
|
||||||
|
}
|
||||||
|
|
||||||
const flatPages = $derived.by<FlatPage[]>(() => {
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
const out: FlatPage[] = [];
|
const out: FlatPage[] = [];
|
||||||
for (const chunk of stripToRender) {
|
for (const chunk of stripChunks) {
|
||||||
for (let i = 0; i < chunk.urls.length; i++) {
|
for (let i = 0; i < chunk.urls.length; i++) {
|
||||||
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
|
out.push({
|
||||||
|
chapterId: chunk.chapterId,
|
||||||
|
chapterName: chunk.chapterName,
|
||||||
|
localIndex: i,
|
||||||
|
url: chunk.urls[i],
|
||||||
|
total: chunk.urls.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
let loadedSet = $state(new Set<number>());
|
let currentSrc = $state<string | null>(null);
|
||||||
let resolvedSrc = $state<Record<number, string>>({});
|
let currentGroupSrcs = $state<(string | null)[]>([]);
|
||||||
let revokeQueue: string[] = [];
|
|
||||||
|
|
||||||
let observer: IntersectionObserver | null = null;
|
|
||||||
const elementIndex = new Map<Element, number>();
|
|
||||||
|
|
||||||
let viewportCenter = $state(0);
|
|
||||||
|
|
||||||
function scheduleRevoke(src: string) {
|
|
||||||
if (!src || !src.startsWith("blob:")) return;
|
|
||||||
revokeQueue.push(src);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const url = revokeQueue.shift();
|
|
||||||
if (url) { try { URL.revokeObjectURL(url); } catch {} }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPage(idx: number) {
|
|
||||||
if (loadedSet.has(idx)) return;
|
|
||||||
const page = flatPages[idx];
|
|
||||||
if (!page) return;
|
|
||||||
const newSet = new Set(loadedSet);
|
|
||||||
newSet.add(idx);
|
|
||||||
loadedSet = newSet;
|
|
||||||
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
|
||||||
resolveUrl(page.url, priority).then(src => {
|
|
||||||
if (loadedSet.has(idx)) {
|
|
||||||
resolvedSrc = { ...resolvedSrc, [idx]: src };
|
|
||||||
} else {
|
|
||||||
scheduleRevoke(src);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function unloadPage(idx: number) {
|
|
||||||
if (!loadedSet.has(idx)) return;
|
|
||||||
const newSet = new Set(loadedSet);
|
|
||||||
newSet.delete(idx);
|
|
||||||
loadedSet = newSet;
|
|
||||||
const oldSrc = resolvedSrc[idx];
|
|
||||||
if (oldSrc) {
|
|
||||||
const next = { ...resolvedSrc };
|
|
||||||
delete next[idx];
|
|
||||||
resolvedSrc = next;
|
|
||||||
scheduleRevoke(oldSrc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function recalcWindow() {
|
|
||||||
const center = viewportCenter;
|
|
||||||
const lo = center - LOAD_RADIUS;
|
|
||||||
const hi = center + LOAD_RADIUS;
|
|
||||||
const evictLo = center - UNLOAD_RADIUS;
|
|
||||||
const evictHi = center + UNLOAD_RADIUS;
|
|
||||||
for (let i = 0; i < flatPages.length; i++) {
|
|
||||||
if (i >= lo && i <= hi) loadPage(i);
|
|
||||||
else if (i < evictLo || i > evictHi) unloadPage(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { void viewportCenter; recalcWindow(); });
|
|
||||||
$effect(() => { void flatPages.length; recalcWindow(); });
|
|
||||||
|
|
||||||
function setupObserver(containerEl: HTMLElement) {
|
|
||||||
observer?.disconnect();
|
|
||||||
elementIndex.clear();
|
|
||||||
observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
let best = -1;
|
|
||||||
let bestRatio = -1;
|
|
||||||
for (const entry of entries) {
|
|
||||||
const idx = elementIndex.get(entry.target);
|
|
||||||
if (idx === undefined) continue;
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
|
||||||
bestRatio = entry.intersectionRatio;
|
|
||||||
best = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
|
|
||||||
},
|
|
||||||
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function observePage(el: HTMLDivElement, idx: number) {
|
|
||||||
elementIndex.set(el, idx);
|
|
||||||
observer?.observe(el);
|
|
||||||
return {
|
|
||||||
update(newIdx: number) { elementIndex.set(el, newIdx); },
|
|
||||||
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastChapterId = 0;
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chapterId = readerState.activeChapter?.id ?? 0;
|
if (style === "longstrip" || !pageReady) return;
|
||||||
if (chapterId === lastChapterId) return;
|
const pageNum = readerState.pageNumber;
|
||||||
lastChapterId = chapterId;
|
const urls = readerState.pageUrls;
|
||||||
loadedSet = new Set<number>();
|
const group = currentGroup;
|
||||||
resolvedSrc = {};
|
currentSrc = null;
|
||||||
const resume = readerState.resumePage;
|
currentGroupSrcs = group.map(() => null);
|
||||||
viewportCenter = resume > 1 ? resume - 1 : 0;
|
let cancelled = false;
|
||||||
|
if (style === "double") {
|
||||||
|
group.forEach((pg, i) => {
|
||||||
|
const url = urls[pg - 1];
|
||||||
|
if (!url) return;
|
||||||
|
resolveUrl(url, 999).then(src => {
|
||||||
|
if (cancelled) return;
|
||||||
|
currentGroupSrcs = currentGroupSrcs.map((s, j) => j === i ? src : s);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const url = urls[pageNum - 1];
|
||||||
|
if (url) resolveUrl(url, 999).then(src => { if (!cancelled) currentSrc = src; });
|
||||||
|
}
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void readerState.pageNumber;
|
||||||
|
if (style !== "longstrip" && containerEl) containerEl.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastTrackedPage = 0;
|
||||||
|
let lastTrackedChapter = 0;
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (style !== "longstrip" || !containerEl || !flatPages.length) return;
|
||||||
|
|
||||||
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
|
const readY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
|
|
||||||
|
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
|
||||||
|
let centerFlatIdx = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
slots.forEach((slot, idx) => {
|
||||||
|
const rect = slot.getBoundingClientRect();
|
||||||
|
const mid = (rect.top + rect.bottom) / 2;
|
||||||
|
const dist = Math.abs(mid - readY);
|
||||||
|
if (dist < bestDist) { bestDist = dist; centerFlatIdx = idx; }
|
||||||
|
});
|
||||||
|
|
||||||
|
onCenterIdxChange(centerFlatIdx);
|
||||||
|
|
||||||
|
const page = flatPages[centerFlatIdx];
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const localPage = page.localIndex + 1;
|
||||||
|
if (localPage !== lastTrackedPage || page.chapterId !== lastTrackedChapter) {
|
||||||
|
lastTrackedPage = localPage;
|
||||||
|
lastTrackedChapter = page.chapterId;
|
||||||
|
onPageChange(localPage);
|
||||||
|
onChapterChange(page.chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chunk of stripChunks) {
|
||||||
|
const lastLocalIdx = chunk.urls.length - 1;
|
||||||
|
let flatLastIdx = -1;
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (flatPages[i].chapterId === chunk.chapterId && flatPages[i].localIndex === lastLocalIdx) {
|
||||||
|
flatLastIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flatLastIdx < 0) continue;
|
||||||
|
const lastSlot = slots[flatLastIdx];
|
||||||
|
if (!lastSlot) continue;
|
||||||
|
const lastRect = lastSlot.getBoundingClientRect();
|
||||||
|
if (lastRect.bottom < readY) onMarkRead(chunk.chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollBottom = containerEl.scrollTop + containerEl.clientHeight;
|
||||||
|
const scrollTotal = containerEl.scrollHeight;
|
||||||
|
if (scrollTotal - scrollBottom < containerEl.clientHeight * 1.5) onAppend();
|
||||||
|
}
|
||||||
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl = $state<HTMLDivElement | undefined>();
|
||||||
|
let stripRef: LongstripViewer | undefined = $state();
|
||||||
|
|
||||||
|
export function captureAnchor() { stripRef?.captureAnchor(); }
|
||||||
|
export function restoreAnchor() { stripRef?.restoreAnchor(); }
|
||||||
|
export function notifyScrollCenter(idx: number) { stripRef?.notifyScrollCenter(idx); }
|
||||||
|
export async function scrollToFlatIndex(idx: number) { await stripRef?.scrollToFlatIndex(idx); }
|
||||||
|
|
||||||
function getInspectImageEl(): HTMLElement | null {
|
function getInspectImageEl(): HTMLElement | null {
|
||||||
if (!containerEl) return null;
|
if (!containerEl) return null;
|
||||||
@@ -183,61 +212,6 @@
|
|||||||
let inspectPanStartX = 0;
|
let inspectPanStartX = 0;
|
||||||
let inspectPanStartY = 0;
|
let inspectPanStartY = 0;
|
||||||
|
|
||||||
let stripDragging = $state(false);
|
|
||||||
let stripDragMoved = false;
|
|
||||||
let stripDragStartY = 0;
|
|
||||||
let stripScrollStart = 0;
|
|
||||||
|
|
||||||
let autoScrollPaused = false;
|
|
||||||
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
let midScrollActive = $state(false);
|
|
||||||
let midScrollOriginY = $state(0);
|
|
||||||
let midScrollCurrentY = 0;
|
|
||||||
let midScrollDisplayLevel = $state(0);
|
|
||||||
let midScrollRaf: number | null = null;
|
|
||||||
|
|
||||||
function startMidScroll(originY: number) {
|
|
||||||
midScrollActive = true;
|
|
||||||
midScrollOriginY = originY;
|
|
||||||
midScrollDisplayLevel = 0;
|
|
||||||
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
|
||||||
const tick = () => {
|
|
||||||
if (!midScrollActive || !containerEl) return;
|
|
||||||
const dy = midScrollCurrentY - midScrollOriginY;
|
|
||||||
const deadZone = 24;
|
|
||||||
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
|
||||||
const speed = Math.sign(dy) * excess * 0.12;
|
|
||||||
containerEl.scrollTop += speed;
|
|
||||||
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
midScrollRaf = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopMidScroll() {
|
|
||||||
midScrollActive = false;
|
|
||||||
midScrollDisplayLevel = 0;
|
|
||||||
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseAutoScroll() {
|
|
||||||
autoScrollPaused = true;
|
|
||||||
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
|
||||||
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style !== "longstrip" || !settingsState.settings.autoScroll) return;
|
|
||||||
let rafId: number;
|
|
||||||
const tick = () => {
|
|
||||||
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(rafId);
|
|
||||||
});
|
|
||||||
|
|
||||||
let pinch: PinchTracker | null = null;
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -255,27 +229,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||||
|
|
||||||
export function onInspectMouseDown(e: MouseEvent) {
|
export function onInspectMouseDown(e: MouseEvent) {
|
||||||
if ((e.target as Element).closest(".bar")) return;
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
if (e.button === 1 && style === "longstrip") {
|
if (style === "longstrip") { stripRef?.onMouseDown(e); return; }
|
||||||
e.preventDefault();
|
|
||||||
if (midScrollActive) {
|
|
||||||
stopMidScroll();
|
|
||||||
} else {
|
|
||||||
settingsState.settings.autoScroll = false;
|
|
||||||
startMidScroll(e.clientY);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (style === "longstrip") {
|
|
||||||
stripDragging = true;
|
|
||||||
stripDragMoved = false;
|
|
||||||
stripDragStartY = e.clientY;
|
|
||||||
stripScrollStart = containerEl?.scrollTop ?? 0;
|
|
||||||
pauseAutoScroll();
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (readerState.inspectScale <= 1) return;
|
if (readerState.inspectScale <= 1) return;
|
||||||
inspectDragging = true;
|
inspectDragging = true;
|
||||||
inspectDragMoved = false;
|
inspectDragMoved = false;
|
||||||
@@ -287,13 +245,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseMove(e: MouseEvent) {
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
midScrollCurrentY = e.clientY;
|
if (style === "longstrip") { stripRef?.onMouseMove(e); return; }
|
||||||
if (stripDragging) {
|
|
||||||
const dy = e.clientY - stripDragStartY;
|
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
|
||||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!inspectDragging) return;
|
if (!inspectDragging) return;
|
||||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
@@ -304,22 +256,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseUp() {
|
export function onInspectMouseUp() {
|
||||||
stripDragging = false;
|
if (style === "longstrip") { stripRef?.onMouseUp(); return; }
|
||||||
inspectDragging = false;
|
inspectDragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerDown(e: PointerEvent) {
|
export function onPointerDown(e: PointerEvent) {
|
||||||
if ((e.target as Element).closest(".bar")) return;
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
pinch?.onPointerDown(e);
|
pinch?.onPointerDown(e);
|
||||||
|
if (style === "longstrip") stripRef?.onPointerDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onPointerMove(e: PointerEvent) {
|
export function onPointerMove(e: PointerEvent) {
|
||||||
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
||||||
if (stripDragging) {
|
if (style === "longstrip") { stripRef?.onPointerMove(e); return; }
|
||||||
const dy = e.clientY - stripDragStartY;
|
|
||||||
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
|
||||||
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
|
||||||
}
|
|
||||||
if (inspectDragging) {
|
if (inspectDragging) {
|
||||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
@@ -332,13 +281,16 @@
|
|||||||
|
|
||||||
export function onPointerUp(e: PointerEvent) {
|
export function onPointerUp(e: PointerEvent) {
|
||||||
pinch?.onPointerUp(e);
|
pinch?.onPointerUp(e);
|
||||||
if (!pinch?.isPinching()) { stripDragging = false; inspectDragging = false; }
|
if (!pinch?.isPinching()) {
|
||||||
|
if (style === "longstrip") stripRef?.onPointerUp();
|
||||||
|
else inspectDragging = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWheel(e: WheelEvent) {
|
export function handleWheel(e: WheelEvent) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (e.ctrlKey) { onWheel(e); }
|
if (e.ctrlKey) onWheel(e);
|
||||||
else pauseAutoScroll();
|
else stripRef?.onWheel(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.ctrlKey) { onWheel(e); return; }
|
if (!e.ctrlKey) { onWheel(e); return; }
|
||||||
@@ -348,14 +300,12 @@
|
|||||||
if (next === readerState.inspectScale) return;
|
if (next === readerState.inspectScale) return;
|
||||||
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
||||||
const img = getInspectImageEl();
|
const img = getInspectImageEl();
|
||||||
const anchor = img ?? containerEl;
|
const anchor = img ?? containerEl ?? null;
|
||||||
const rect = anchor?.getBoundingClientRect();
|
const rect = anchor?.getBoundingClientRect();
|
||||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||||
const ratio = next / readerState.inspectScale;
|
const ratio = next / readerState.inspectScale;
|
||||||
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
|
const [clampedX, clampedY] = clampInspectPan(next, cx + (readerState.inspectPanX - cx) * ratio, cy + (readerState.inspectPanY - cy) * ratio);
|
||||||
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
|
|
||||||
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
|
||||||
readerState.inspectScale = next;
|
readerState.inspectScale = next;
|
||||||
readerState.inspectPanX = clampedX;
|
readerState.inspectPanX = clampedX;
|
||||||
readerState.inspectPanY = clampedY;
|
readerState.inspectPanY = clampedY;
|
||||||
@@ -365,11 +315,10 @@
|
|||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
if (stripRef?.consumeTap()) return;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
|
||||||
if (tapToToggleBar) {
|
if (tapToToggleBar) {
|
||||||
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
|
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
|
||||||
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
|
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
|
||||||
@@ -388,18 +337,7 @@
|
|||||||
function setContainer(el: HTMLDivElement) {
|
function setContainer(el: HTMLDivElement) {
|
||||||
containerEl = el;
|
containerEl = el;
|
||||||
bindContainer(el);
|
bindContainer(el);
|
||||||
if (style === "longstrip") setupObserver(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (style === "longstrip" && containerEl) {
|
|
||||||
setupObserver(containerEl);
|
|
||||||
} else if (style !== "longstrip") {
|
|
||||||
observer?.disconnect();
|
|
||||||
observer = null;
|
|
||||||
stopMidScroll();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -413,10 +351,10 @@
|
|||||||
onclick={handleTap}
|
onclick={handleTap}
|
||||||
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
||||||
ondblclick={handleDblClick}
|
ondblclick={handleDblClick}
|
||||||
|
onscroll={style === "longstrip" ? handleScroll : undefined}
|
||||||
onmousedown={onInspectMouseDown}
|
onmousedown={onInspectMouseDown}
|
||||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||||
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === " " && style === "longstrip") {
|
if (e.key === " " && style === "longstrip") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -426,28 +364,9 @@
|
|||||||
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if midScrollActive}
|
|
||||||
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
|
||||||
<div class="midscroll-segments">
|
|
||||||
{#each [5,4,3,2,1] as n}
|
|
||||||
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
|
||||||
{/each}
|
|
||||||
<div class="midscroll-origin-dot"></div>
|
|
||||||
{#each [1,2,3,4,5] as n}
|
|
||||||
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center-overlay">
|
<div class="center-overlay">
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
{@render skeleton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -456,74 +375,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if style === "longstrip"}
|
{#if style === "longstrip"}
|
||||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
<LongstripViewer
|
||||||
{@const src = resolvedSrc[gi]}
|
bind:this={stripRef}
|
||||||
{@const isLoaded = loadedSet.has(gi)}
|
{containerEl}
|
||||||
<div class="strip-slot" use:observePage={gi}>
|
{flatPages}
|
||||||
{#if isLoaded && src}
|
{imgCls}
|
||||||
<img
|
{effectiveWidth}
|
||||||
{src}
|
{resolveUrl}
|
||||||
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
{barPosition}
|
||||||
data-local-page={page.localIndex + 1}
|
|
||||||
data-chapter={page.chapterId}
|
|
||||||
data-total={page.total}
|
|
||||||
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
|
|
||||||
loading="eager"
|
|
||||||
decoding="async"
|
|
||||||
draggable="false"
|
|
||||||
onload={(e) => {
|
|
||||||
const img = e.currentTarget as HTMLImageElement;
|
|
||||||
const slot = img.closest<HTMLElement>(".strip-slot");
|
|
||||||
if (slot && img.naturalWidth > 0) {
|
|
||||||
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="strip-placeholder" aria-hidden="true">
|
|
||||||
{@render skeleton()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div style="height:1px;flex-shrink:0"></div>
|
|
||||||
|
|
||||||
{:else if style === "fade" && pageReady}
|
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
|
||||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{:then src}
|
|
||||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if style === "double" && pageReady}
|
{:else if style === "double" && pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<DoubleViewer {imgCls} {currentGroup} srcs={currentGroupSrcs} {pageGroups} />
|
||||||
{#if pageGroups.length}
|
|
||||||
<div class="double-wrap">
|
|
||||||
{#each currentGroup as pg, i (pg)}
|
|
||||||
{#await resolveUrl(readerState.pageUrls[pg - 1], 999)}
|
|
||||||
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{:then src}
|
|
||||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="center-overlay">
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if pageReady}
|
{:else if pageReady}
|
||||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
<SingleViewer {imgCls} src={currentSrc} {fadingOut} isFade={style === "fade"} />
|
||||||
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
|
||||||
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
|
||||||
{:then src}
|
|
||||||
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -546,19 +412,6 @@
|
|||||||
|
|
||||||
:global(.pinch-active) .viewer { touch-action: none; }
|
:global(.pinch-active) .viewer { touch-action: none; }
|
||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
|
||||||
|
|
||||||
.strip-slot { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
|
||||||
|
|
||||||
.strip-placeholder {
|
|
||||||
width: var(--effective-width, 100%);
|
|
||||||
max-width: var(--effective-width, 100%);
|
|
||||||
aspect-ratio: var(--aspect, 0.667);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
.page-loader-single {
|
.page-loader-single {
|
||||||
width: min(100%, var(--effective-width, 100%));
|
width: min(100%, var(--effective-width, 100%));
|
||||||
@@ -589,47 +442,14 @@
|
|||||||
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
:global(.img) { display: block; user-select: none; image-rendering: auto; }
|
||||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
:global(.img.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||||
:global(.strip-gap) { margin-bottom: 8px; }
|
:global(.strip-gap) { margin-bottom: 8px; }
|
||||||
|
|
||||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
|
||||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gap-left { margin-right: 2px; }
|
|
||||||
.gap-right { margin-left: 2px; }
|
|
||||||
|
|
||||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
|
||||||
.midscroll-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 6px;
|
|
||||||
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
|
||||||
pointer-events: auto;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.midscroll-bar-right { right: 8px; }
|
|
||||||
.midscroll-bar-left { left: 8px; }
|
|
||||||
|
|
||||||
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
|
||||||
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
|
|
||||||
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
|
|
||||||
.midscroll-seg-lit { background: var(--accent-fg); }
|
|
||||||
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
|
|
||||||
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack, tick } from "svelte";
|
import { onMount, untrack, tick } from "svelte";
|
||||||
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
||||||
|
import type { PageStyle } from "$lib/state/reader.svelte";
|
||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
import { app } from "$lib/state/app.svelte";
|
import { app, appState } from "$lib/state/app.svelte";
|
||||||
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||||
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
||||||
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
|
|
||||||
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
||||||
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
||||||
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
||||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||||
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||||
import { historyState } from "$lib/state/history.svelte";
|
import { historyState } from "$lib/state/history.svelte";
|
||||||
|
import { setPreviewManga, seriesState } from "$lib/state/series.svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { setReading, clearReading } from "$lib/core/discord";
|
||||||
|
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||||
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||||
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
||||||
import PageView from "$lib/components/reader/PageView.svelte";
|
import PageView from "$lib/components/reader/PageView.svelte";
|
||||||
@@ -42,17 +46,26 @@
|
|||||||
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
||||||
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
||||||
|
|
||||||
|
let visibleChapterId = $state<number | null>(null);
|
||||||
|
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && readerState.visibleChapterId
|
style === "longstrip" && visibleChapterId
|
||||||
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
|
? (readerState.activeChapterList.find(c => c.id === visibleChapterId) ?? readerState.activeChapter)
|
||||||
: readerState.activeChapter
|
: readerState.activeChapter
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentBookmark = $derived(
|
const currentBookmark = $derived(
|
||||||
readerState.activeManga
|
readerState.activeManga
|
||||||
? readerState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
? seriesState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
const currentGroup = $derived.by(() => {
|
||||||
|
const group = style === "double" && readerState.pageGroups.length
|
||||||
|
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||||
|
: [readerState.pageNumber];
|
||||||
|
return rtl ? [...group].reverse() : group;
|
||||||
|
});
|
||||||
|
|
||||||
const isBookmarked = $derived(
|
const isBookmarked = $derived(
|
||||||
!!currentBookmark &&
|
!!currentBookmark &&
|
||||||
currentBookmark.chapterId === displayChapter?.id &&
|
currentBookmark.chapterId === displayChapter?.id &&
|
||||||
@@ -67,7 +80,7 @@
|
|||||||
|
|
||||||
const showResumeBanner = $derived(
|
const showResumeBanner = $derived(
|
||||||
readerState.resumeVisible && readerState.resumePage > 1 &&
|
readerState.resumeVisible && readerState.resumePage > 1 &&
|
||||||
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
|
readerState.pageNumber === readerState.resumePage
|
||||||
);
|
);
|
||||||
|
|
||||||
const adjacent = $derived.by(() => {
|
const adjacent = $derived.by(() => {
|
||||||
@@ -83,9 +96,9 @@
|
|||||||
|
|
||||||
const visibleChunkLastPage = $derived.by(() => {
|
const visibleChunkLastPage = $derived.by(() => {
|
||||||
if (style !== "longstrip") return lastPage;
|
if (style !== "longstrip") return lastPage;
|
||||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||||
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
|
const chId = visibleChapterId ?? readerState.activeChapter?.id;
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunks.find(c => c.chapterId === chId)?.urls.length ?? lastPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgCls = $derived([
|
const imgCls = $derived([
|
||||||
@@ -97,21 +110,6 @@
|
|||||||
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||||
].filter(Boolean).join(" "));
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
const stripToRender = $derived(
|
|
||||||
style === "longstrip"
|
|
||||||
? (readerState.stripChapters.length > 0
|
|
||||||
? readerState.stripChapters
|
|
||||||
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentGroup = $derived.by(() => {
|
|
||||||
const group = style === "double" && readerState.pageGroups.length
|
|
||||||
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
|
||||||
: [readerState.pageNumber];
|
|
||||||
return rtl ? [...group].reverse() : group;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderPage = $derived.by(() => {
|
const sliderPage = $derived.by(() => {
|
||||||
if (style === "double" && readerState.pageGroups.length)
|
if (style === "double" && readerState.pageGroups.length)
|
||||||
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
|
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
|
||||||
@@ -141,11 +139,8 @@
|
|||||||
let abortCtrl = { current: null as AbortController | null };
|
let abortCtrl = { current: null as AbortController | null };
|
||||||
let hasNavigated = false;
|
let hasNavigated = false;
|
||||||
let startAtLastPageRef = { current: false };
|
let startAtLastPageRef = { current: false };
|
||||||
let cleanupScroll: () => void = () => {};
|
|
||||||
let stripChaptersRef = readerState.stripChapters;
|
|
||||||
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
let tickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
function maybeMarkCurrentRead() {
|
||||||
const ch = displayChapter ?? readerState.activeChapter;
|
const ch = displayChapter ?? readerState.activeChapter;
|
||||||
@@ -207,6 +202,24 @@
|
|||||||
|
|
||||||
const startAtLast = () => { startAtLastPageRef.current = true; };
|
const startAtLast = () => { startAtLastPageRef.current = true; };
|
||||||
|
|
||||||
|
function primedJump(page: number, commit = true) {
|
||||||
|
if (useBlob && commit && style !== "longstrip") {
|
||||||
|
cancelQueuedFetches();
|
||||||
|
const urls = readerState.pageUrls;
|
||||||
|
const lo = Math.max(0, page - 2);
|
||||||
|
const hi = Math.min(urls.length, page + 4);
|
||||||
|
preloadBlobUrls(urls.slice(lo, hi), 999);
|
||||||
|
}
|
||||||
|
jumpToPage(
|
||||||
|
page,
|
||||||
|
style,
|
||||||
|
lastPage,
|
||||||
|
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
|
||||||
|
visibleChapterId ?? readerState.activeChapter?.id ?? 0,
|
||||||
|
pageViewRef?.getStripChunks() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const goNext = $derived(rtl
|
const goNext = $derived(rtl
|
||||||
? () => goBack(style, adjacent, startAtLast)
|
? () => goBack(style, adjacent, startAtLast)
|
||||||
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
||||||
@@ -214,15 +227,21 @@
|
|||||||
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
||||||
: () => goBack(style, adjacent, startAtLast));
|
: () => goBack(style, adjacent, startAtLast));
|
||||||
|
|
||||||
|
function handleCloseReader() {
|
||||||
|
clearReading().catch(() => {});
|
||||||
|
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||||
|
readerState.closeReader();
|
||||||
|
}
|
||||||
|
|
||||||
const onKey = createReaderKeyHandler({
|
const onKey = createReaderKeyHandler({
|
||||||
goNext: () => goNext(),
|
goNext: () => goNext(),
|
||||||
goPrev: () => goPrev(),
|
goPrev: () => goPrev(),
|
||||||
closeReader: () => readerState.closeReader(),
|
closeReader: () => handleCloseReader(),
|
||||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
goToPage: (p) => primedJump(p),
|
||||||
lastPage: () => lastPage,
|
lastPage: () => lastPage,
|
||||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] as PageStyle }); },
|
||||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||||
openSettings: () => { app.setSettingsOpen(true); },
|
openSettings: () => { app.setSettingsOpen(true); },
|
||||||
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
||||||
@@ -237,11 +256,11 @@
|
|||||||
},
|
},
|
||||||
chapterNext: () => {
|
chapterNext: () => {
|
||||||
const ch = rtl ? adjacent.prev : adjacent.next;
|
const ch = rtl ? adjacent.prev : adjacent.next;
|
||||||
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeChapterList); }
|
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeManga); }
|
||||||
},
|
},
|
||||||
chapterPrev: () => {
|
chapterPrev: () => {
|
||||||
const ch = rtl ? adjacent.next : adjacent.prev;
|
const ch = rtl ? adjacent.next : adjacent.prev;
|
||||||
if (ch) readerState.openReader(ch, readerState.activeChapterList);
|
if (ch) readerState.openReader(ch, readerState.activeManga);
|
||||||
},
|
},
|
||||||
closePopovers: () => readerState.closeAllPopovers(),
|
closePopovers: () => readerState.closeAllPopovers(),
|
||||||
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
|
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
|
||||||
@@ -251,7 +270,7 @@
|
|||||||
|
|
||||||
function captureCurrentReaderSettings(): ReaderSettings {
|
function captureCurrentReaderSettings(): ReaderSettings {
|
||||||
return {
|
return {
|
||||||
pageStyle: style,
|
pageStyle: style as PageStyle,
|
||||||
fitMode: fit,
|
fitMode: fit,
|
||||||
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
|
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
|
||||||
readerZoom: zoom,
|
readerZoom: zoom,
|
||||||
@@ -299,9 +318,10 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const manga = readerState.activeManga;
|
if (ch) {
|
||||||
if (ch && manga) {
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (!manga) return;
|
||||||
historyState.openSession(
|
historyState.openSession(
|
||||||
manga.id, manga.title, manga.thumbnailUrl,
|
manga.id, manga.title, manga.thumbnailUrl,
|
||||||
ch.id, ch.name, readerState.pageNumber,
|
ch.id, ch.name, readerState.pageNumber,
|
||||||
@@ -311,10 +331,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const ch = readerState.activeChapter;
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
const idle = appState.idleSplash;
|
||||||
|
if (ch && manga && !idle) {
|
||||||
|
untrack(() => setReading(manga, ch).catch(() => {}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const page = readerState.pageNumber;
|
const page = readerState.pageNumber;
|
||||||
const chId = style === "longstrip"
|
const chId = style === "longstrip"
|
||||||
? (readerState.visibleChapterId ?? readerState.activeChapter?.id)
|
? (visibleChapterId ?? readerState.activeChapter?.id)
|
||||||
: readerState.activeChapter?.id;
|
: readerState.activeChapter?.id;
|
||||||
const chName = style === "longstrip"
|
const chName = style === "longstrip"
|
||||||
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
|
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
|
||||||
@@ -337,38 +366,19 @@
|
|||||||
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
||||||
const ch = readerState.activeChapter;
|
const ch = readerState.activeChapter;
|
||||||
const urls = readerState.pageUrls;
|
const urls = readerState.pageUrls;
|
||||||
const targetPg = untrack(() => readerState.resumePage);
|
const resumeTo = untrack(() => readerState.resumePage);
|
||||||
|
visibleChapterId = ch.id;
|
||||||
appending = false;
|
appending = false;
|
||||||
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
pageViewRef.loadStrip(ch.id, ch.name, urls, resumeTo);
|
||||||
readerState.visibleChapterId = ch.id;
|
|
||||||
tick().then(() => {
|
|
||||||
if (!containerEl) return;
|
|
||||||
if (targetPg > 1) {
|
|
||||||
const chId = ch.id;
|
|
||||||
const scrollToResumePage = () => {
|
|
||||||
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
|
||||||
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
|
||||||
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
|
||||||
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
|
|
||||||
if (target.complete && target.naturalHeight > 0) doScroll();
|
|
||||||
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
|
||||||
};
|
|
||||||
scrollToResumePage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
containerEl!.scrollTop = 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chId = readerState.visibleChapterId;
|
const chId = visibleChapterId;
|
||||||
if (!chId || style !== "longstrip") return;
|
if (!chId || style !== "longstrip") return;
|
||||||
if (chId === readerState.activeChapter?.id) return;
|
if (chId === readerState.activeChapter?.id) return;
|
||||||
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
|
||||||
if (wasAppended) {
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
readerState.resumePage = 0;
|
readerState.resumePage = 0;
|
||||||
readerState.resumeVisible = false;
|
readerState.resumeVisible = false;
|
||||||
@@ -380,76 +390,10 @@
|
|||||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||||
.filter(c => !c.downloaded && !c.read)
|
.filter(c => !c.downloaded && !c.read)
|
||||||
.map(c => c.id);
|
.map(c => c.id);
|
||||||
if (toQueue.length) {
|
if (toQueue.length) getAdapter().enqueueDownloads(toQueue.map(String)).catch(console.error);
|
||||||
const DL = `mutation EnqueueDl($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
|
||||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query: DL, variables: { ids: toQueue } }) })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
|
|
||||||
if (bookmark && bookmark.pageNumber > 1) {
|
|
||||||
untrack(() => {
|
|
||||||
readerState.resumePage = bookmark.pageNumber;
|
|
||||||
readerState.resumeDismissed = false;
|
|
||||||
readerState.resumeVisible = true;
|
|
||||||
readerState.stripResumeReady = true;
|
|
||||||
scheduleResumeDismiss();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
untrack(() => readerState.resetResume());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void style;
|
|
||||||
if (!containerEl) return;
|
|
||||||
untrack(() => {
|
|
||||||
cleanupScroll();
|
|
||||||
cleanupScroll = setupScrollTracking(containerEl!, {
|
|
||||||
onPageChange: (p) => { readerState.pageNumber = p; },
|
|
||||||
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
|
||||||
onMarkRead: (id) => markChapterRead(id, markedRead),
|
|
||||||
onAppend: () => {
|
|
||||||
if (appending || !readerState.stripChapters.length) return;
|
|
||||||
appending = true;
|
|
||||||
appendNextChapter(
|
|
||||||
stripChaptersRef,
|
|
||||||
readerState.activeChapterList,
|
|
||||||
(id) => fetchPages(id, useBlob),
|
|
||||||
(url) => preloadImage(url, useBlob),
|
|
||||||
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
|
||||||
() => { appending = false; },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getStripChapters: () => stripChaptersRef,
|
|
||||||
getPageUrls: () => readerState.pageUrls,
|
|
||||||
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (readerState.activeChapter && readerState.activeChapterList.length) {
|
|
||||||
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const next = readerState.activeChapterList[idx + 1];
|
|
||||||
const prev = readerState.activeChapterList[idx - 1];
|
|
||||||
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
||||||
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -518,13 +462,19 @@
|
|||||||
if (pageNum > 1) hasNavigated = true;
|
if (pageNum > 1) hasNavigated = true;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (!hasNavigated) return;
|
if (!hasNavigated) return;
|
||||||
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||||
if (settingsState.settings.autoBookmark ?? true) {
|
if (settingsState.settings.autoBookmark ?? true) {
|
||||||
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
seriesState.setBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||||
if (existing) readerState.removeBookmark(existing.chapterId);
|
|
||||||
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
|
||||||
}
|
}
|
||||||
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
||||||
|
|
||||||
|
if (pageNum > 1 && !markedRead.has(chapterId)) {
|
||||||
|
if (progressTimer) clearTimeout(progressTimer);
|
||||||
|
progressTimer = setTimeout(() => {
|
||||||
|
getAdapter().updateChaptersProgress([String(chapterId)], { lastPageRead: pageNum }).catch(console.error);
|
||||||
|
progressTimer = null;
|
||||||
|
}, 2_000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -558,12 +508,12 @@
|
|||||||
abortCtrl.current?.abort();
|
abortCtrl.current?.abort();
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
if (roTimer) clearTimeout(roTimer);
|
if (roTimer) clearTimeout(roTimer);
|
||||||
|
if (progressTimer) clearTimeout(progressTimer);
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||||
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||||
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
||||||
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
||||||
cleanupScroll();
|
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -607,6 +557,7 @@
|
|||||||
onClampZoom={clampZoom}
|
onClampZoom={clampZoom}
|
||||||
onApplySettings={applySettings}
|
onApplySettings={applySettings}
|
||||||
onSettingsOpen={() => { app.setSettingsOpen(true); }}
|
onSettingsOpen={() => { app.setSettingsOpen(true); }}
|
||||||
|
onOpenPreview={() => { if (readerState.activeManga) setPreviewManga(readerState.activeManga); }}
|
||||||
{perMangaEnabled}
|
{perMangaEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -633,6 +584,7 @@
|
|||||||
resumePage={readerState.resumePage}
|
resumePage={readerState.resumePage}
|
||||||
resumeFading={readerState.resumeFading}
|
resumeFading={readerState.resumeFading}
|
||||||
{adjacent}
|
{adjacent}
|
||||||
|
{barPosition}
|
||||||
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
|
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -643,10 +595,11 @@
|
|||||||
error={readerState.error}
|
error={readerState.error}
|
||||||
pageReady={readerState.pageReady}
|
pageReady={readerState.pageReady}
|
||||||
pageGroups={readerState.pageGroups}
|
pageGroups={readerState.pageGroups}
|
||||||
{currentGroup} {stripToRender}
|
{currentGroup}
|
||||||
fadingOut={readerState.fadingOut}
|
fadingOut={readerState.fadingOut}
|
||||||
{tapToToggleBar}
|
{tapToToggleBar}
|
||||||
{pinchZoomEnabled}
|
{pinchZoomEnabled}
|
||||||
|
{useBlob}
|
||||||
{barPosition}
|
{barPosition}
|
||||||
onGetZoom={() => zoom}
|
onGetZoom={() => zoom}
|
||||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
@@ -655,6 +608,28 @@
|
|||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onToggleUi={toggleUiVisibility}
|
onToggleUi={toggleUiVisibility}
|
||||||
{bindContainer}
|
{bindContainer}
|
||||||
|
onPageChange={(p) => { readerState.pageNumber = p; }}
|
||||||
|
onChapterChange={(id) => { visibleChapterId = id; }}
|
||||||
|
onCenterIdxChange={(idx) => { pageViewRef?.notifyScrollCenter(idx); }}
|
||||||
|
onMarkRead={(id) => markChapterRead(id, markedRead)}
|
||||||
|
onAppend={() => {
|
||||||
|
if (appending) return;
|
||||||
|
const chunks = pageViewRef?.getStripChunks() ?? [];
|
||||||
|
if (!chunks.length) return;
|
||||||
|
const lastChunk = chunks[chunks.length - 1];
|
||||||
|
const list = readerState.activeChapterList;
|
||||||
|
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
||||||
|
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||||
|
const next = list[lastIdx + 1];
|
||||||
|
if (!next || chunks.some(c => c.chapterId === next.id)) return;
|
||||||
|
appending = true;
|
||||||
|
fetchPages(next.id, useBlob)
|
||||||
|
.then(urls => {
|
||||||
|
urls.slice(0, 6).forEach(url => preloadImage(url, useBlob));
|
||||||
|
return pageViewRef.appendStripChunk(next.id, next.name, urls);
|
||||||
|
})
|
||||||
|
.finally(() => { appending = false; });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#snippet progressBarSnippet()}
|
{#snippet progressBarSnippet()}
|
||||||
@@ -667,7 +642,7 @@
|
|||||||
{barPosition}
|
{barPosition}
|
||||||
onGoPrev={goPrev}
|
onGoPrev={goPrev}
|
||||||
onGoNext={goNext}
|
onGoNext={goNext}
|
||||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -681,7 +656,7 @@
|
|||||||
{barPosition}
|
{barPosition}
|
||||||
onGoPrev={goPrev}
|
onGoPrev={goPrev}
|
||||||
onGoNext={goNext}
|
onGoNext={goNext}
|
||||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
onJumpToPage={(p, commit) => primedJump(p, commit)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
||||||
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||||
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
||||||
ArrowsOut, ArrowsIn,
|
ArrowsOut, ArrowsIn, Minus,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { platformService } from "$lib/platform-service";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import { cubicOut, cubicIn } from "svelte/easing";
|
import { cubicOut, cubicIn } from "svelte/easing";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
onClampZoom: (z: number) => number;
|
onClampZoom: (z: number) => number;
|
||||||
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||||
onSettingsOpen: () => void;
|
onSettingsOpen: () => void;
|
||||||
|
onOpenPreview: () => void;
|
||||||
perMangaEnabled: boolean;
|
perMangaEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,30 +48,12 @@
|
|||||||
barPosition, progressBar,
|
barPosition, progressBar,
|
||||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||||
onClampZoom, onApplySettings, onSettingsOpen,
|
onClampZoom, onApplySettings, onSettingsOpen, onOpenPreview,
|
||||||
perMangaEnabled,
|
perMangaEnabled,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||||
|
|
||||||
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
|
||||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
const res = await fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query, variables }) });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
|
||||||
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
|
||||||
|
|
||||||
async function runDl(fn: () => Promise<void>) {
|
async function runDl(fn: () => Promise<void>) {
|
||||||
readerState.dlBusy = true;
|
readerState.dlBusy = true;
|
||||||
try { await fn(); } catch (e) { console.error(e); }
|
try { await fn(); } catch (e) { console.error(e); }
|
||||||
@@ -77,6 +61,14 @@
|
|||||||
readerState.dlOpen = false;
|
readerState.dlOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enqueueOne(chapterId: number) {
|
||||||
|
return getAdapter().enqueueDownload(String(chapterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueMany(chapterIds: number[]) {
|
||||||
|
return getAdapter().enqueueDownloads(chapterIds.map(String));
|
||||||
|
}
|
||||||
|
|
||||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
const popoverSide = $derived(
|
const popoverSide = $derived(
|
||||||
barPosition === "left" ? "right" :
|
barPosition === "left" ? "right" :
|
||||||
@@ -96,9 +88,10 @@
|
|||||||
onRestoreZoomAnchor();
|
onRestoreZoomAnchor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTauri = platformService.platform === "tauri";
|
||||||
|
|
||||||
async function toggleFullscreen() {
|
async function toggleFullscreen() {
|
||||||
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
|
await platformService.toggleFullscreen();
|
||||||
else await document.exitFullscreen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllPopovers() {
|
function closeAllPopovers() {
|
||||||
@@ -146,7 +139,7 @@
|
|||||||
<div class="bar-divider"></div>
|
<div class="bar-divider"></div>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
|
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev); } }}
|
||||||
disabled={!adjacent.prev}
|
disabled={!adjacent.prev}
|
||||||
title="Previous chapter">
|
title="Previous chapter">
|
||||||
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
|
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
|
||||||
@@ -163,11 +156,11 @@
|
|||||||
<span class="ch-info"></span>
|
<span class="ch-info"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||||
<span class="ch-marquee-content">
|
<button class="ch-marquee-content ch-preview-btn" onclick={onOpenPreview}>
|
||||||
<span class="ch-title">{readerState.activeManga?.title}</span>
|
<span class="ch-title">{readerState.activeManga?.title}</span>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-sep">/</span>
|
||||||
<span class="ch-name">{displayChapter?.name}</span>
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
</span>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
|
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next); } }}
|
||||||
disabled={!adjacent.next}
|
disabled={!adjacent.next}
|
||||||
title="Next chapter">
|
title="Next chapter">
|
||||||
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
|
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
|
||||||
@@ -337,6 +330,16 @@
|
|||||||
<span>Fullscreen</span>
|
<span>Fullscreen</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if isTauri}
|
||||||
|
<button class="action-row" onclick={() => { readerState.actionsOpen = false; platformService.minimize(); }}>
|
||||||
|
<Minus size={13} weight="regular" />
|
||||||
|
<span>Minimize</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-row action-row-danger" onclick={() => { readerState.actionsOpen = false; platformService.close(); }}>
|
||||||
|
<X size={13} weight="regular" />
|
||||||
|
<span>Close window</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -345,13 +348,13 @@
|
|||||||
<div class="popover dl-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
|
<div class="popover dl-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
<p class="dl-title">Download</p>
|
<p class="dl-title">Download</p>
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
onclick={() => runDl(() => enqueueOne(chapter.id))}>
|
||||||
This chapter
|
This chapter
|
||||||
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
onclick={() => runDl(() => enqueueMany(queueable.slice(0, readerState.nextN).map(c => c.id)))}>
|
||||||
Next chapters
|
Next chapters
|
||||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -362,7 +365,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
onclick={() => runDl(() => enqueueMany(queueable.map(c => c.id)))}>
|
||||||
All remaining
|
All remaining
|
||||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -492,6 +495,8 @@
|
|||||||
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
||||||
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||||
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
||||||
|
.ch-preview-btn { background: none; border: none; cursor: pointer; padding: 0; font-size: inherit; font-family: inherit; border-radius: var(--radius-sm); transition: opacity var(--t-fast); }
|
||||||
|
.ch-preview-btn:hover { opacity: 0.7; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-name { color: var(--text-muted); }
|
.ch-name { color: var(--text-muted); }
|
||||||
@@ -661,8 +666,10 @@
|
|||||||
transition: background var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
}
|
}
|
||||||
.action-row:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
.action-row:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.action-row.action-row-danger:hover { background: color-mix(in srgb, #c0392b 15%, transparent); color: var(--color-error, #e57373); }
|
||||||
.action-row svg, .action-row :global(svg) { flex-shrink: 0; color: var(--text-faint); }
|
.action-row svg, .action-row :global(svg) { flex-shrink: 0; color: var(--text-faint); }
|
||||||
.action-row:hover svg, .action-row:hover :global(svg) { color: var(--text-muted); }
|
.action-row:hover svg, .action-row:hover :global(svg) { color: var(--text-muted); }
|
||||||
|
.action-row-danger:hover svg, .action-row-danger:hover :global(svg) { color: var(--color-error, #e57373); }
|
||||||
|
|
||||||
.action-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
.action-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
@@ -15,28 +14,6 @@
|
|||||||
|
|
||||||
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
|
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
|
||||||
|
|
||||||
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
|
||||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
|
||||||
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
|
||||||
|
|
||||||
async function runDl(fn: () => Promise<void>) {
|
async function runDl(fn: () => Promise<void>) {
|
||||||
readerState.dlBusy = true;
|
readerState.dlBusy = true;
|
||||||
try { await fn(); } catch (e) { console.error(e); }
|
try { await fn(); } catch (e) { console.error(e); }
|
||||||
@@ -44,11 +21,27 @@
|
|||||||
readerState.dlOpen = false;
|
readerState.dlOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bannerMounted = $state(false);
|
||||||
|
let bannerFading = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showResumeBanner) {
|
||||||
|
bannerMounted = true;
|
||||||
|
bannerFading = false;
|
||||||
|
} else if (bannerMounted) {
|
||||||
|
bannerFading = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||||
|
|
||||||
|
function onBannerAnimationEnd() {
|
||||||
|
if (bannerFading) { bannerMounted = false; bannerFading = false; }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showResumeBanner}
|
{#if bannerMounted}
|
||||||
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
|
<button class="resume-banner" class:fading={bannerFading} onclick={onDismissResume} onanimationend={onBannerAnimationEnd}>
|
||||||
Bookmark at page {resumePage}
|
Bookmark at page {resumePage}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -60,14 +53,14 @@
|
|||||||
<p class="dl-title">Download</p>
|
<p class="dl-title">Download</p>
|
||||||
|
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownload(String(chapter.id)))}>
|
||||||
This chapter
|
This chapter
|
||||||
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.slice(0, readerState.nextN).map(c => String(c.id))))}>
|
||||||
Next chapters
|
Next chapters
|
||||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -79,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.map(c => String(c.id))))}>
|
||||||
All remaining
|
All remaining
|
||||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
barPosition: "top" | "left" | "right";
|
barPosition: "top" | "left" | "right";
|
||||||
onGoPrev: () => void;
|
onGoPrev: () => void;
|
||||||
onGoNext: () => void;
|
onGoNext: () => void;
|
||||||
onJumpToPage: (page: number) => void;
|
onJumpToPage: (page: number, commit?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -32,12 +32,22 @@
|
|||||||
|
|
||||||
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
|
||||||
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
|
|
||||||
const hPct = $derived(`--pct:${sliderPct}%`);
|
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||||
|
|
||||||
|
function sliderValToPage(raw: number): number {
|
||||||
|
return rtl ? sliderMax - raw + 1 : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageToSliderVal(page: number): number {
|
||||||
|
return rtl ? sliderMax - page + 1 : page;
|
||||||
|
}
|
||||||
|
|
||||||
function handleH(e: Event) {
|
function handleH(e: Event) {
|
||||||
const raw = Number((e.target as HTMLInputElement).value);
|
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), false);
|
||||||
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
|
}
|
||||||
|
|
||||||
|
function handleHCommit(e: Event) {
|
||||||
|
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerPct(pageNumber: number, forRtl = false): number {
|
function markerPct(pageNumber: number, forRtl = false): number {
|
||||||
@@ -46,9 +56,9 @@
|
|||||||
return ((ord - 1) / (sliderMax - 1)) * 100;
|
return ((ord - 1) / (sliderMax - 1)) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom vertical slider
|
|
||||||
let trackEl = $state<HTMLDivElement | null>(null);
|
let trackEl = $state<HTMLDivElement | null>(null);
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
|
let pendingPage = 0;
|
||||||
|
|
||||||
function pctFromPointer(clientY: number): number {
|
function pctFromPointer(clientY: number): number {
|
||||||
if (!trackEl) return 0;
|
if (!trackEl) return 0;
|
||||||
@@ -66,20 +76,22 @@
|
|||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
dragging = true;
|
dragging = true;
|
||||||
readerState.sliderDragging = true;
|
readerState.sliderDragging = true;
|
||||||
const pct = pctFromPointer(e.clientY);
|
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||||
onJumpToPage(pageFromPct(pct));
|
onJumpToPage(pendingPage, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrackPointerMove(e: PointerEvent) {
|
function handleTrackPointerMove(e: PointerEvent) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const pct = pctFromPointer(e.clientY);
|
pendingPage = pageFromPct(pctFromPointer(e.clientY));
|
||||||
onJumpToPage(pageFromPct(pct));
|
onJumpToPage(pendingPage, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrackPointerUp(e: PointerEvent) {
|
function handleTrackPointerUp(e: PointerEvent) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
dragging = false;
|
dragging = false;
|
||||||
readerState.sliderDragging = false;
|
readerState.sliderDragging = false;
|
||||||
|
readerState.sliderHover = false;
|
||||||
|
onJumpToPage(pendingPage, true);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -102,8 +114,9 @@
|
|||||||
style={hPct}
|
style={hPct}
|
||||||
min={1}
|
min={1}
|
||||||
max={sliderMax}
|
max={sliderMax}
|
||||||
value={hValue}
|
value={pageToSliderVal(sliderPage)}
|
||||||
oninput={handleH}
|
oninput={handleH}
|
||||||
|
onchange={handleHCommit}
|
||||||
onmousedown={() => readerState.sliderDragging = true}
|
onmousedown={() => readerState.sliderDragging = true}
|
||||||
onmouseup={() => readerState.sliderDragging = false}
|
onmouseup={() => readerState.sliderDragging = false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
||||||
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import type { MangaPrefs } from "$lib/types/settings";
|
import type { MangaPrefs } from "$lib/types/settings";
|
||||||
@@ -18,7 +19,7 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
|||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
|
|
||||||
if (manga && chapter) {
|
if (manga && chapter) {
|
||||||
readerState.addBookmark({
|
seriesState.setBookmark({
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
mangaTitle: manga.title,
|
mangaTitle: manga.title,
|
||||||
thumbnailUrl: manga.thumbnailUrl,
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
@@ -35,8 +36,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
|||||||
const mangaId = readerState.activeManga?.id;
|
const mangaId = readerState.activeManga?.id;
|
||||||
if (!mangaId) return;
|
if (!mangaId) return;
|
||||||
|
|
||||||
readerState.activeChapterList = readerState.activeChapterList.map(c =>
|
seriesState.patchChapters(mangaId, chapters =>
|
||||||
c.id === id ? { ...c, read: true } : c
|
chapters.map(c => c.id === id ? { ...c, read: true } : c),
|
||||||
);
|
);
|
||||||
|
|
||||||
const prefs = getMangaPrefs(mangaId);
|
const prefs = getMangaPrefs(mangaId);
|
||||||
@@ -79,15 +80,13 @@ export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNu
|
|||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
if (!chapter || !manga) return;
|
if (!chapter || !manga) return;
|
||||||
|
|
||||||
const existing = readerState.bookmarks.find(
|
const existing = seriesState.bookmarks.find(
|
||||||
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
readerState.removeBookmark(chapter.id);
|
seriesState.removeBookmark(chapter.id);
|
||||||
} else {
|
} else {
|
||||||
const other = readerState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
seriesState.setBookmark({
|
||||||
if (other) readerState.removeBookmark(other.chapterId);
|
|
||||||
readerState.addBookmark({
|
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
mangaTitle: manga.title,
|
mangaTitle: manga.title,
|
||||||
thumbnailUrl: manga.thumbnailUrl,
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
@@ -103,9 +102,9 @@ export function commitMarker(color: MarkerColor, note: string, editId: string) {
|
|||||||
const manga = readerState.activeManga;
|
const manga = readerState.activeManga;
|
||||||
if (!chapter || !manga) return;
|
if (!chapter || !manga) return;
|
||||||
if (editId) {
|
if (editId) {
|
||||||
readerState.updateMarker(editId, { note: note.trim(), color });
|
seriesState.updateMarker(editId, { note: note.trim(), color });
|
||||||
} else {
|
} else {
|
||||||
readerState.addMarker({
|
seriesState.addMarker({
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
mangaTitle: manga.title,
|
mangaTitle: manga.title,
|
||||||
thumbnailUrl: manga.thumbnailUrl,
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
import { cancelQueuedFetches } from "$lib/core/cache/imageCache";
|
import { cancelQueuedFetches, revokeBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||||
import { clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prefetchedChapterId: number | null = null;
|
||||||
|
let prefetchedUrls: string[] = [];
|
||||||
|
|
||||||
export async function loadChapter(
|
export async function loadChapter(
|
||||||
id: number,
|
id: number,
|
||||||
useBlob: boolean,
|
useBlob: boolean,
|
||||||
@@ -21,30 +25,54 @@ export async function loadChapter(
|
|||||||
abortCtrl.current = ctrl;
|
abortCtrl.current = ctrl;
|
||||||
|
|
||||||
cancelQueuedFetches();
|
cancelQueuedFetches();
|
||||||
if (useBlob) clearResolvedUrlCache();
|
if (useBlob) {
|
||||||
|
clearResolvedUrlCache();
|
||||||
|
for (const url of readerState.pageUrls) revokeBlobUrl(url);
|
||||||
|
if (prefetchedChapterId !== null && prefetchedChapterId !== id) {
|
||||||
|
for (const url of prefetchedUrls) revokeBlobUrl(url);
|
||||||
|
clearPageCache(prefetchedChapterId);
|
||||||
|
}
|
||||||
|
prefetchedChapterId = null;
|
||||||
|
prefetchedUrls = [];
|
||||||
|
}
|
||||||
|
|
||||||
startAtLastPage.current = false;
|
startAtLastPage.current = false;
|
||||||
markedRead.clear();
|
markedRead.clear();
|
||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
readerState.pageUrls = [];
|
readerState.pageUrls = [];
|
||||||
|
|
||||||
const bookmark = readerState.bookmarks.find(b => b.chapterId === id);
|
const bookmark = seriesState.bookmarks.find(b => b.chapterId === id);
|
||||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||||
readerState.resumeDismissed = false;
|
readerState.resumeDismissed = false;
|
||||||
readerState.resumeVisible = resumeTo > 1;
|
readerState.resumeVisible = false;
|
||||||
if (resumeTo > 1) scheduleResumeDismiss();
|
|
||||||
|
|
||||||
readerState.pageNumber = 1;
|
readerState.pageNumber = 1;
|
||||||
try {
|
try {
|
||||||
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.pageUrls = urls;
|
readerState.pageUrls = urls;
|
||||||
|
if (useBlob && resumeTo > 1) {
|
||||||
|
const lo = Math.max(0, resumeTo - 2);
|
||||||
|
const hi = Math.min(urls.length, resumeTo + 4);
|
||||||
|
preloadBlobUrls(urls.slice(lo, hi), 900);
|
||||||
|
}
|
||||||
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
||||||
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
readerState.pageReady = true;
|
readerState.pageReady = true;
|
||||||
readerState.loading = false;
|
readerState.loading = false;
|
||||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
if (resumeTo > 1) readerState.resumeVisible = true;
|
||||||
|
|
||||||
|
if (adjacent.next) {
|
||||||
|
prefetchedChapterId = adjacent.next.id;
|
||||||
|
fetchPages(adjacent.next.id, useBlob, ctrl.signal)
|
||||||
|
.then(fetched => {
|
||||||
|
if (!ctrl.signal.aborted && prefetchedChapterId === adjacent.next!.id) {
|
||||||
|
prefetchedUrls = fetched;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
readerState.error = e instanceof Error ? e.message : String(e);
|
readerState.error = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: ()
|
|||||||
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
||||||
if (forward) {
|
if (forward) {
|
||||||
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||||
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next, readerState.activeChapterList); }
|
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next); }
|
||||||
else closeReader();
|
else closeReader();
|
||||||
} else {
|
} else {
|
||||||
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function goForward(
|
|||||||
) {
|
) {
|
||||||
if (readerState.loading) return;
|
if (readerState.loading) return;
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, readerState.activeChapterList); }
|
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||||
@@ -46,14 +46,14 @@ export function goForward(
|
|||||||
} else if (adjacent.next) {
|
} else if (adjacent.next) {
|
||||||
onMaybeMarkRead();
|
onMaybeMarkRead();
|
||||||
readerState.pageNumber = 1;
|
readerState.pageNumber = 1;
|
||||||
openReader(adjacent.next, readerState.activeChapterList);
|
openReader(adjacent.next);
|
||||||
} else closeReader();
|
} else closeReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||||
if (readerState.loading) return;
|
if (readerState.loading) return;
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||||
@@ -61,17 +61,32 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
|
|||||||
if (readerState.pageNumber > 1) {
|
if (readerState.pageNumber > 1) {
|
||||||
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
||||||
else readerState.pageNumber--;
|
else readerState.pageNumber--;
|
||||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
export function jumpToPage(
|
||||||
|
page: number,
|
||||||
|
style: string,
|
||||||
|
lastPage: number,
|
||||||
|
scrollToFlatIndex: ((idx: number) => void) | null,
|
||||||
|
activeChapterId: number,
|
||||||
|
chunks: { chapterId: number; urls: string[] }[],
|
||||||
|
) {
|
||||||
if (style === "longstrip") {
|
if (style === "longstrip") {
|
||||||
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
if (!scrollToFlatIndex || !chunks.length) return;
|
||||||
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (chunk.chapterId === activeChapterId) {
|
||||||
|
scrollToFlatIndex(offset + Math.max(0, page - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offset += chunk.urls.length;
|
||||||
|
}
|
||||||
|
scrollToFlatIndex(Math.max(0, page - 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (style === "double" && readerState.pageGroups.length) {
|
if (style === "double" && readerState.pageGroups.length) {
|
||||||
const group = readerState.pageGroups[page - 1];
|
const group = readerState.pageGroups.find(g => g.includes(page)) ?? readerState.pageGroups.findLast(g => g[0] <= page);
|
||||||
if (group) readerState.pageNumber = group[0];
|
if (group) readerState.pageNumber = group[0];
|
||||||
} else {
|
} else {
|
||||||
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache, getCachedAspect } from "$lib/core/cache/pageCache";
|
||||||
|
|
||||||
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
||||||
const groups: number[][] = [[1]];
|
const groups: number[][] = [[1]];
|
||||||
|
|||||||
@@ -1,100 +1 @@
|
|||||||
export const READ_LINE_PCT = 0.50;
|
export const READ_LINE_PCT = 0.50;
|
||||||
|
|
||||||
export interface StripChapter {
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
urls: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScrollHandlerCallbacks {
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onChapterChange: (chapterId: number) => void;
|
|
||||||
onMarkRead: (chapterId: number) => void;
|
|
||||||
onAppend: () => void;
|
|
||||||
getStripChapters: () => StripChapter[];
|
|
||||||
getPageUrls: () => string[];
|
|
||||||
shouldAutoMark: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupScrollTracking(
|
|
||||||
containerEl: HTMLElement,
|
|
||||||
callbacks: ScrollHandlerCallbacks,
|
|
||||||
): () => void {
|
|
||||||
const { onPageChange, onChapterChange, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
|
|
||||||
let rafId: number | null = null;
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
rafId = null;
|
|
||||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
|
||||||
if (!imgs.length) return;
|
|
||||||
|
|
||||||
const containerTop = containerEl.getBoundingClientRect().top;
|
|
||||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
|
||||||
|
|
||||||
let lo = 0, hi = imgs.length - 1, best = 0;
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >>> 1;
|
|
||||||
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
|
||||||
else hi = mid - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = imgs[best];
|
|
||||||
const activePage = Number(active.dataset.localPage);
|
|
||||||
const activeChId = Number(active.dataset.chapter);
|
|
||||||
|
|
||||||
onPageChange(activePage);
|
|
||||||
if (activeChId) onChapterChange(activeChId);
|
|
||||||
|
|
||||||
if (shouldAutoMark() && activeChId) {
|
|
||||||
const chunks = getStripChapters();
|
|
||||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
|
||||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
|
||||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
|
||||||
|
|
||||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
|
||||||
if (atBottom) {
|
|
||||||
const last = chunks[chunks.length - 1];
|
|
||||||
if (last) onMarkRead(last.chapterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
|
||||||
if (pct >= 0.80) onAppend();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll() {
|
|
||||||
if (rafId !== null) return;
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
containerEl.removeEventListener("scroll", onScroll);
|
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendNextChapter(
|
|
||||||
stripChapters: StripChapter[],
|
|
||||||
chapterList: { id: number; name: string }[],
|
|
||||||
fetchPages: (chapterId: number) => Promise<string[]>,
|
|
||||||
preloadImage: (url: string) => void,
|
|
||||||
onAppended: (next: StripChapter) => void,
|
|
||||||
onDone: () => void,
|
|
||||||
): void {
|
|
||||||
if (!stripChapters.length) return;
|
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
|
||||||
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
|
||||||
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
|
||||||
const next = chapterList[lastIdx + 1];
|
|
||||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
|
||||||
|
|
||||||
fetchPages(next.id)
|
|
||||||
.then(urls => { urls.slice(0, 6).forEach(preloadImage); return urls; })
|
|
||||||
.then(urls => {
|
|
||||||
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
|
||||||
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
|
||||||
onDone();
|
|
||||||
})
|
|
||||||
.catch(() => onDone());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgCls: string;
|
||||||
|
currentGroup: number[];
|
||||||
|
srcs: (string | null)[];
|
||||||
|
pageGroups: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imgCls, currentGroup, srcs, pageGroups }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inspect-wrap"
|
||||||
|
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
|
||||||
|
>
|
||||||
|
{#if pageGroups.length}
|
||||||
|
<div class="double-wrap">
|
||||||
|
{#each currentGroup as pg, i (pg)}
|
||||||
|
{#if srcs[i]}
|
||||||
|
<img
|
||||||
|
src={srcs[i]}
|
||||||
|
alt="Page {pg}"
|
||||||
|
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
||||||
|
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||||
|
.gap-left { margin-right: 2px; }
|
||||||
|
.gap-right { margin-left: 2px; }
|
||||||
|
|
||||||
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
|
.page-loader-single {
|
||||||
|
width: min(100%, var(--effective-width, 100%));
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { getCachedAspect } from "$lib/components/reader/lib/pageLoader";
|
||||||
|
|
||||||
|
export interface StripPage {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
localIndex: number;
|
||||||
|
url: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerEl: HTMLDivElement | undefined;
|
||||||
|
flatPages: StripPage[];
|
||||||
|
imgCls: string;
|
||||||
|
effectiveWidth: number | undefined;
|
||||||
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { containerEl, flatPages, imgCls, effectiveWidth, resolveUrl, barPosition }: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
let _loadedSet: Set<number> = new Set();
|
||||||
|
let _resolvedSrc: Record<number, string> = {};
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
|
const loadedSet = { has: (i: number) => _loadedSet.has(i) };
|
||||||
|
const resolvedSrc = { get: (i: number) => _resolvedSrc[i] as string | undefined };
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let centerIdx = $state(0);
|
||||||
|
const aspectMap = new Map<number, number>();
|
||||||
|
|
||||||
|
function scheduleRevoke(src: string) {
|
||||||
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
|
revokeQueue.push(src);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const url = revokeQueue.shift();
|
||||||
|
if (url) { try { URL.revokeObjectURL(url); } catch {} }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(idx: number) {
|
||||||
|
if (_loadedSet.has(idx)) return;
|
||||||
|
const page = flatPages[idx];
|
||||||
|
if (!page) return;
|
||||||
|
_loadedSet.add(idx);
|
||||||
|
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
||||||
|
resolveUrl(page.url, priority).then(src => {
|
||||||
|
if (_loadedSet.has(idx)) {
|
||||||
|
_resolvedSrc[idx] = src;
|
||||||
|
_version++;
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!_loadedSet.has(idx)) return;
|
||||||
|
_loadedSet.delete(idx);
|
||||||
|
const aspect = aspectMap.get(idx);
|
||||||
|
if (aspect !== undefined && containerEl) {
|
||||||
|
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
|
||||||
|
slot?.style.setProperty("--aspect", String(aspect));
|
||||||
|
}
|
||||||
|
const oldSrc = _resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
delete _resolvedSrc[idx];
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
_version++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function recalcWindow(center: number) {
|
||||||
|
const lo = center - LOAD_RADIUS;
|
||||||
|
const hi = center + LOAD_RADIUS;
|
||||||
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
|
const evictHi = center + UNLOAD_RADIUS;
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (i >= lo && i <= hi) loadPage(i);
|
||||||
|
else if (i < evictLo || i > evictHi) unloadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecalc(center: number) {
|
||||||
|
if (recalcTimer) return;
|
||||||
|
recalcTimer = setTimeout(() => { recalcTimer = null; recalcWindow(center); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { void _version; });
|
||||||
|
$effect(() => { recalcWindow(centerIdx); });
|
||||||
|
$effect(() => { void flatPages.length; tick().then(() => recalcWindow(centerIdx)); });
|
||||||
|
|
||||||
|
let lastChapterId = 0;
|
||||||
|
$effect(() => {
|
||||||
|
let chapterId: number;
|
||||||
|
try { chapterId = readerState.activeChapter?.id ?? 0; } catch { return; }
|
||||||
|
if (chapterId === lastChapterId) return;
|
||||||
|
lastChapterId = chapterId;
|
||||||
|
_loadedSet = new Set<number>();
|
||||||
|
_resolvedSrc = {};
|
||||||
|
centerIdx = 0;
|
||||||
|
_version++;
|
||||||
|
aspectMap.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export function notifyScrollCenter(idx: number) {
|
||||||
|
centerIdx = idx;
|
||||||
|
scheduleRecalc(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrollToFlatIndex(idx: number) {
|
||||||
|
if (!containerEl || !flatPages.length) return;
|
||||||
|
centerIdx = idx;
|
||||||
|
recalcWindow(idx);
|
||||||
|
await tick();
|
||||||
|
if (!containerEl) return;
|
||||||
|
const slot = containerEl.querySelectorAll<HTMLElement>(".strip-slot")[idx];
|
||||||
|
if (slot) slot.scrollIntoView({ block: "start", behavior: "instant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchorEl: HTMLElement | null = null;
|
||||||
|
let anchorOffset = 0;
|
||||||
|
|
||||||
|
export function captureAnchor() {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
let best: HTMLElement | null = null;
|
||||||
|
let bestTop = -Infinity;
|
||||||
|
for (const img of imgs) {
|
||||||
|
const top = img.getBoundingClientRect().top;
|
||||||
|
if (top <= readY && top > bestTop) { bestTop = top; best = img; }
|
||||||
|
}
|
||||||
|
anchorEl = best;
|
||||||
|
anchorOffset = best ? readY - best.getBoundingClientRect().top : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreAnchor() {
|
||||||
|
if (!containerEl || !anchorEl) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!anchorEl || !containerEl) return;
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
const delta = (readY - anchorEl.getBoundingClientRect().top) - anchorOffset;
|
||||||
|
containerEl.scrollTop -= delta;
|
||||||
|
anchorEl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let autoScrollPaused = false;
|
||||||
|
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function pauseAutoScroll() {
|
||||||
|
autoScrollPaused = true;
|
||||||
|
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
||||||
|
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!settingsState.settings.autoScroll || !containerEl) return;
|
||||||
|
let rafId: number;
|
||||||
|
const tick = () => {
|
||||||
|
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const HIDE_AFTER_MS = 5_000;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const show = () => {
|
||||||
|
containerEl.style.cursor = "";
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => { if (containerEl) containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
|
||||||
|
};
|
||||||
|
show();
|
||||||
|
window.addEventListener("mousemove", show, { passive: true });
|
||||||
|
return () => {
|
||||||
|
if (containerEl) containerEl.style.cursor = "";
|
||||||
|
window.removeEventListener("mousemove", show);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let midScrollActive = $state(false);
|
||||||
|
let midScrollOriginY = $state(0);
|
||||||
|
let midScrollCurrentY = 0;
|
||||||
|
let midScrollDisplayLevel = $state(0);
|
||||||
|
let midScrollRaf: number | null = null;
|
||||||
|
|
||||||
|
function startMidScroll(originY: number) {
|
||||||
|
midScrollActive = true;
|
||||||
|
midScrollOriginY = originY;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
||||||
|
const frame = () => {
|
||||||
|
if (!midScrollActive || !containerEl) return;
|
||||||
|
const dy = midScrollCurrentY - midScrollOriginY;
|
||||||
|
const excess = Math.max(0, Math.abs(dy) - 24);
|
||||||
|
containerEl.scrollTop += Math.sign(dy) * excess * 0.12;
|
||||||
|
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||||
|
midScrollRaf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
midScrollRaf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMidScroll() {
|
||||||
|
midScrollActive = false;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let stripDragging = false;
|
||||||
|
let stripDragMoved = false;
|
||||||
|
let stripDragStartY = 0;
|
||||||
|
let stripScrollStart = 0;
|
||||||
|
|
||||||
|
function setDragCursor(dragging: boolean) {
|
||||||
|
if (containerEl) containerEl.style.cursor = dragging ? "grabbing" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
if (e.button === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (midScrollActive) stopMidScroll();
|
||||||
|
else { settingsState.settings.autoScroll = false; startMidScroll(e.clientY); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
setDragCursor(true);
|
||||||
|
pauseAutoScroll();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseMove(e: MouseEvent) {
|
||||||
|
midScrollCurrentY = e.clientY;
|
||||||
|
if (!stripDragging) return;
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMouseUp() {
|
||||||
|
stripDragging = false;
|
||||||
|
setDragCursor(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
setDragCursor(true);
|
||||||
|
pauseAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!stripDragging) return;
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerUp() {
|
||||||
|
stripDragging = false;
|
||||||
|
setDragCursor(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeTap(): boolean {
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onWheel(e: WheelEvent) {
|
||||||
|
if (!e.ctrlKey) pauseAutoScroll();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if midScrollActive}
|
||||||
|
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
||||||
|
<div class="midscroll-segments">
|
||||||
|
{#each [5,4,3,2,1] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
<div class="midscroll-origin-dot"></div>
|
||||||
|
{#each [1,2,3,4,5] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||||
|
{@const src = _version >= 0 ? resolvedSrc.get(gi) : undefined}
|
||||||
|
{@const isLoaded = _version >= 0 ? loadedSet.has(gi) : false}
|
||||||
|
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId} style={getCachedAspect(page.url) != null ? `--aspect:${getCachedAspect(page.url)}` : undefined}>
|
||||||
|
{#if isLoaded && src}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
|
data-local-page={page.localIndex + 1}
|
||||||
|
data-chapter={page.chapterId}
|
||||||
|
data-total={page.total}
|
||||||
|
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
onload={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
|
if (slot && img.naturalWidth > 0) {
|
||||||
|
const aspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
slot.style.setProperty("--aspect", String(aspect));
|
||||||
|
aspectMap.set(gi, aspect);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="strip-placeholder" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.strip-slot { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
|
||||||
|
.strip-placeholder {
|
||||||
|
width: var(--effective-width, 100%);
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.midscroll-bar {
|
||||||
|
position: fixed; top: 50%; transform: translateY(-50%);
|
||||||
|
z-index: 200; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 8px; padding: 10px 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
||||||
|
border: 1px solid var(--border-base); border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
||||||
|
pointer-events: auto; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.midscroll-bar-right { right: 8px; }
|
||||||
|
.midscroll-bar-left { left: 8px; }
|
||||||
|
|
||||||
|
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||||
|
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
|
||||||
|
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
|
||||||
|
.midscroll-seg-lit { background: var(--accent-fg); }
|
||||||
|
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
|
||||||
|
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgCls: string;
|
||||||
|
src: string | null;
|
||||||
|
fadingOut: boolean;
|
||||||
|
isFade: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imgCls, src, fadingOut, isFade }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inspect-wrap"
|
||||||
|
style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)"
|
||||||
|
>
|
||||||
|
{#if src}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="Page {readerState.pageNumber}"
|
||||||
|
class={imgCls}
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
style={isFade ? `opacity:${fadingOut ? 0 : 1};transition:opacity 0.1s ease` : undefined}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
|
.page-loader-single {
|
||||||
|
width: min(100%, var(--effective-width, 100%));
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong); stroke-width: 0.8; fill: none;
|
||||||
|
stroke-dasharray: 400; stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,15 +4,15 @@
|
|||||||
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
|
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
|
||||||
import { homeState, clearHistory } from '$lib/state/home.svelte'
|
import { homeState, clearHistory } from '$lib/state/home.svelte'
|
||||||
import { historyState } from '$lib/state/history.svelte'
|
import { historyState } from '$lib/state/history.svelte'
|
||||||
import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte'
|
import { setActiveManga, openReaderForChapter, setPreviewManga } from '$lib/state/series.svelte'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||||
import { groupByDay } from './lib/recentHistory'
|
import { groupByDay } from './lib/recentHistory'
|
||||||
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
||||||
import RecentToolbar from './RecentToolbar.svelte'
|
import RecentToolbar from './RecentToolbar.svelte'
|
||||||
import UpdatesTab from './UpdatesTab.svelte'
|
import UpdatesTab from './UpdatesTab.svelte'
|
||||||
import HistoryTab from './HistoryTab.svelte'
|
import HistoryTab from './HistoryTab.svelte'
|
||||||
import type { Manga } from '$lib/types'
|
import type { Manga, Chapter } from '$lib/types'
|
||||||
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||||
import type { HistoryGroup } from './lib/recentHistory'
|
import type { HistoryGroup } from './lib/recentHistory'
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
let updatesLoading: boolean = $state(true)
|
let updatesLoading: boolean = $state(true)
|
||||||
let updatesError: string | null = $state(null)
|
let updatesError: string | null = $state(null)
|
||||||
let openingId: number | null = $state(null)
|
let openingId: number | null = $state(null)
|
||||||
|
let enqueueing: Set<number> = $state(new Set())
|
||||||
let updaterRunning: boolean = $state(false)
|
let updaterRunning: boolean = $state(false)
|
||||||
let lastUpdatedTs: number | null = $state(null)
|
let lastUpdatedTs: number | null = $state(null)
|
||||||
let updaterFinishedJobs: number | null = $state(null)
|
let updaterFinishedJobs: number | null = $state(null)
|
||||||
@@ -121,9 +122,9 @@
|
|||||||
if (force) cache.clear(key)
|
if (force) cache.clear(key)
|
||||||
|
|
||||||
const [updatesRes, statusRes] = await Promise.all([
|
const [updatesRes, statusRes] = await Promise.all([
|
||||||
cache.get<RecentUpdate[]>(
|
cache.get<Chapter[]>(
|
||||||
key,
|
key,
|
||||||
() => getAdapter().getRecentlyUpdated(nextCtrl.signal),
|
() => getAdapter().getRecentlyUpdated(),
|
||||||
RECENT_UPDATES_TTL_MS,
|
RECENT_UPDATES_TTL_MS,
|
||||||
CACHE_GROUPS.LIBRARY,
|
CACHE_GROUPS.LIBRARY,
|
||||||
),
|
),
|
||||||
@@ -137,7 +138,7 @@
|
|||||||
if (nextCtrl.signal.aborted) return
|
if (nextCtrl.signal.aborted) return
|
||||||
|
|
||||||
updates = (updatesRes ?? [])
|
updates = (updatesRes ?? [])
|
||||||
.filter(item => item.manga?.inLibrary)
|
.map(item => ({ ...item, isRead: item.read }))
|
||||||
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a))
|
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (nextCtrl.signal.aborted) return
|
if (nextCtrl.signal.aborted) return
|
||||||
@@ -168,13 +169,11 @@
|
|||||||
const manga = mangaStub(item)
|
const manga = mangaStub(item)
|
||||||
try {
|
try {
|
||||||
const chapters = await getAdapter().getChapters(String(item.mangaId))
|
const chapters = await getAdapter().getChapters(String(item.mangaId))
|
||||||
const sorted = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const target = chapters.find(ch => ch.id === item.id)
|
||||||
const list = buildChapterList(sorted, {})
|
if (target) openReaderForChapter(target, manga)
|
||||||
const target = list.find(ch => ch.id === item.id)
|
else setPreviewManga(manga)
|
||||||
if (target) { setActiveManga(manga); openReader(target, list) }
|
|
||||||
else setActiveManga(manga)
|
|
||||||
} catch {
|
} catch {
|
||||||
setActiveManga(manga)
|
setPreviewManga(manga)
|
||||||
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
|
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
|
||||||
} finally {
|
} finally {
|
||||||
openingId = null
|
openingId = null
|
||||||
@@ -194,6 +193,42 @@
|
|||||||
clearHistory()
|
clearHistory()
|
||||||
historyConfirmClear = false
|
historyConfirmClear = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enqueueUpdate(item: RecentUpdate) {
|
||||||
|
if (enqueueing.has(item.id)) return
|
||||||
|
enqueueing = new Set(enqueueing).add(item.id)
|
||||||
|
try {
|
||||||
|
const allowed = await downloadStore.enqueue(item.id)
|
||||||
|
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: item.name ?? 'Chapter' })
|
||||||
|
} catch {
|
||||||
|
addToast({ kind: 'error', title: 'Download failed', body: 'Could not queue chapter.' })
|
||||||
|
} finally {
|
||||||
|
enqueueing.delete(item.id)
|
||||||
|
enqueueing = new Set(enqueueing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDownloaded(item: RecentUpdate) {
|
||||||
|
try {
|
||||||
|
await getAdapter().deleteDownloadedChapters([String(item.id)])
|
||||||
|
updates = updates.map(u => u.id === item.id ? { ...u, isDownloaded: false } : u)
|
||||||
|
} catch {
|
||||||
|
addToast({ kind: 'error', title: 'Delete failed', body: 'Could not delete download.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLibraryUpdate() {
|
||||||
|
try {
|
||||||
|
if (updaterRunning) {
|
||||||
|
await getAdapter().stopLibraryUpdate()
|
||||||
|
} else {
|
||||||
|
await getAdapter().startLibraryUpdate()
|
||||||
|
scheduleStatusPoll()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: 'error', title: 'Update error', body: e?.message ?? 'Failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
@@ -218,13 +253,16 @@
|
|||||||
error={updatesError}
|
error={updatesError}
|
||||||
groups={updateGroups}
|
groups={updateGroups}
|
||||||
{updatesSearch}
|
{updatesSearch}
|
||||||
totalCount={updates.length}
|
totalCount={updates.filter(u => !u.isRead).length}
|
||||||
{openingId}
|
{openingId}
|
||||||
|
{enqueueing}
|
||||||
{updaterRunning}
|
{updaterRunning}
|
||||||
{lastUpdatedLabel}
|
{lastUpdatedLabel}
|
||||||
{updaterProgressLabel}
|
{updaterProgressLabel}
|
||||||
onOpenUpdate={openUpdate}
|
onOpenUpdate={openUpdate}
|
||||||
onOpenSeries={(item) => setActiveManga(mangaStub(item))}
|
onOpenSeries={(item) => setActiveManga(mangaStub(item))}
|
||||||
|
onEnqueue={enqueueUpdate}
|
||||||
|
onDeleteDownload={deleteDownloaded}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<HistoryTab
|
<HistoryTab
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory, updatesLoading,
|
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
||||||
onTabChange, onHistorySearchChange, onUpdatesSearchChange, onHistoryClear, onRefreshUpdates,
|
updatesLoading,
|
||||||
|
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
||||||
|
onHistoryClear, onRefreshUpdates,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
onclick={onRefreshUpdates}
|
onclick={onRefreshUpdates}
|
||||||
disabled={updatesLoading}
|
disabled={updatesLoading}
|
||||||
title="Refresh updates"
|
title="Reload update list"
|
||||||
>
|
>
|
||||||
{#if updatesLoading}
|
{#if updatesLoading}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
@@ -79,19 +81,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
class="icon-btn"
|
|
||||||
onclick={onRefreshUpdates}
|
|
||||||
disabled={updatesLoading}
|
|
||||||
title="Refresh library"
|
|
||||||
>
|
|
||||||
{#if updatesLoading}
|
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else}
|
|
||||||
<ArrowsClockwise size={14} weight="bold" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if hasHistory}
|
{#if hasHistory}
|
||||||
<button
|
<button
|
||||||
class="clear-btn"
|
class="clear-btn"
|
||||||
@@ -155,6 +144,8 @@
|
|||||||
}
|
}
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.icon-btn.running { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); background: var(--color-error-bg); }
|
||||||
|
.icon-btn.running:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||||
|
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.search-wrap :global(.search-icon) { position: absolute; left: 8px; color: var(--text-faint); pointer-events: none; }
|
.search-wrap :global(.search-icon) { position: absolute; left: 8px; color: var(--text-faint); pointer-events: none; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BookOpen, CircleNotch } from 'phosphor-svelte'
|
import { BookOpen, CircleNotch, Download, Trash } from 'phosphor-svelte'
|
||||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||||
|
|
||||||
@@ -10,17 +10,20 @@
|
|||||||
updatesSearch: string
|
updatesSearch: string
|
||||||
totalCount: number
|
totalCount: number
|
||||||
openingId: number | null
|
openingId: number | null
|
||||||
|
enqueueing: Set<number>
|
||||||
updaterRunning: boolean
|
updaterRunning: boolean
|
||||||
lastUpdatedLabel: string | null
|
lastUpdatedLabel: string | null
|
||||||
updaterProgressLabel: string | null
|
updaterProgressLabel: string | null
|
||||||
onOpenUpdate: (item: RecentUpdate) => void
|
onOpenUpdate: (item: RecentUpdate) => void
|
||||||
onOpenSeries: (item: RecentUpdate) => void
|
onOpenSeries: (item: RecentUpdate) => void
|
||||||
|
onEnqueue: (item: RecentUpdate) => void
|
||||||
|
onDeleteDownload: (item: RecentUpdate) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
loading, error, groups, updatesSearch, totalCount, openingId,
|
loading, error, groups, updatesSearch, totalCount, openingId, enqueueing,
|
||||||
updaterRunning, lastUpdatedLabel, updaterProgressLabel,
|
updaterRunning, lastUpdatedLabel, updaterProgressLabel,
|
||||||
onOpenUpdate, onOpenSeries,
|
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
const filteredGroups = $derived(updatesSearch.trim()
|
const filteredGroups = $derived(updatesSearch.trim()
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
<div class="bar-sep"></div>
|
<div class="bar-sep"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !loading && totalCount > 0}
|
{#if !loading && totalCount > 0}
|
||||||
<span class="status-count">{totalCount} chapter{totalCount === 1 ? '' : 's'}</span>
|
<span class="status-count">{totalCount} unread</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +141,7 @@
|
|||||||
<div class="update-info">
|
<div class="update-info">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
||||||
{#if !item.isRead}<span class="pill">Unread</span>{/if}
|
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="chapter-title">{chapterLabel(item)}</span>
|
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||||
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||||
@@ -146,6 +149,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="row-end">
|
<div class="row-end">
|
||||||
|
{#if enqueueing.has(item.id)}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if item.isDownloaded}
|
||||||
|
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); onDeleteDownload(item) }} title="Delete download">
|
||||||
|
<Trash size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); onEnqueue(item) }} title="Download">
|
||||||
|
<Download size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if openingId === item.id}
|
{#if openingId === item.id}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -258,12 +272,20 @@
|
|||||||
.chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); }
|
.chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
.meta-row { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.meta-row { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.pill {
|
.pill {
|
||||||
padding: 2px 6px; border-radius: var(--radius-full);
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
background: var(--color-success, #22c55e); flex-shrink: 0;
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); text-transform: uppercase; flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.row-end { color: var(--text-faint); display: flex; align-items: center; justify-content: center; width: 24px; flex-shrink: 0; }
|
.row-end { color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-1); justify-content: center; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.dl-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||||||
|
border: none; background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
.dl-btn-delete { color: var(--color-error); }
|
||||||
|
.dl-btn-delete:hover { background: var(--color-error-bg); }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ export interface UpdateStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchedAtMs(item: Pick<RecentUpdate, 'fetchedAt'>): number {
|
export function fetchedAtMs(item: Pick<RecentUpdate, 'fetchedAt'>): number {
|
||||||
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now()
|
if (!item.fetchedAt) return Date.now()
|
||||||
|
const numeric = Number(item.fetchedAt)
|
||||||
|
if (Number.isFinite(numeric)) return numeric * 1000
|
||||||
|
const ts = new Date(item.fetchedAt).getTime()
|
||||||
return Number.isFinite(ts) ? ts : Date.now()
|
return Number.isFinite(ts) ? ts : Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +45,18 @@ export function parseServerTimestamp(value: unknown): number | null {
|
|||||||
|
|
||||||
export function groupUpdatesByDay(updates: RecentUpdate[]): UpdateGroup[] {
|
export function groupUpdatesByDay(updates: RecentUpdate[]): UpdateGroup[] {
|
||||||
const grouped: Record<string, RecentUpdate[]> = {}
|
const grouped: Record<string, RecentUpdate[]> = {}
|
||||||
|
const order: Record<string, number> = {}
|
||||||
for (const item of updates) {
|
for (const item of updates) {
|
||||||
const label = dayLabel(fetchedAtMs(item))
|
const ts = fetchedAtMs(item)
|
||||||
if (!grouped[label]) grouped[label] = []
|
const label = dayLabel(ts)
|
||||||
|
if (!grouped[label]) {
|
||||||
|
grouped[label] = []
|
||||||
|
order[label] = ts
|
||||||
|
}
|
||||||
grouped[label].push(item)
|
grouped[label].push(item)
|
||||||
|
if (ts > order[label]) order[label] = ts
|
||||||
}
|
}
|
||||||
return Object.entries(grouped).map(([label, items]) => ({ label, items }))
|
return Object.entries(grouped)
|
||||||
|
.sort(([a], [b]) => order[b] - order[a])
|
||||||
|
.map(([label, items]) => ({ label, items }))
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Download, CheckCircle, Circle, CircleNotch, Trash } from 'phosphor-svelte'
|
import { Download, CheckSquare, Square, CircleNotch, Trash } from 'phosphor-svelte'
|
||||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||||
import { longPress } from '$lib/core/ui/touchscreen'
|
import { longPress } from '$lib/core/ui/touchscreen'
|
||||||
@@ -14,27 +14,39 @@
|
|||||||
enqueueing: Set<number>
|
enqueueing: Set<number>
|
||||||
chapterPage: number
|
chapterPage: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
scrollEl?: HTMLDivElement | null
|
|
||||||
onOpen: (ch: Chapter, inProgress: boolean) => void
|
onOpen: (ch: Chapter, inProgress: boolean) => void
|
||||||
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void
|
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void
|
||||||
onEnqueue: (ch: Chapter, e: MouseEvent) => void
|
onEnqueue: (ch: Chapter, e: MouseEvent) => void
|
||||||
onDeleteDownload:(id: number) => void
|
onDeleteDownload:(id: number) => void
|
||||||
onPageChange: (page: number) => void
|
onPageChange: (page: number) => void
|
||||||
|
onPageSizeChange:(n: number) => void
|
||||||
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[]
|
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
pageChapters, sortedChapters, viewMode, loadingChapters,
|
pageChapters, sortedChapters, viewMode, loadingChapters,
|
||||||
selectedIds, enqueueing, chapterPage, totalPages,
|
selectedIds, enqueueing, chapterPage, totalPages,
|
||||||
scrollEl = $bindable(null),
|
|
||||||
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
||||||
onPageChange, buildCtxItems,
|
onPageChange, onPageSizeChange, buildCtxItems,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null)
|
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null)
|
||||||
|
let listEl: HTMLDivElement | null = $state(null)
|
||||||
|
|
||||||
const hasSelection = $derived(selectedIds.size > 0)
|
const hasSelection = $derived(selectedIds.size > 0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!listEl || viewMode !== 'list') return
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
const firstRow = listEl!.querySelector('.ch-row') as HTMLElement | null
|
||||||
|
const rowH = firstRow ? firstRow.offsetHeight : 37
|
||||||
|
const n = Math.max(1, Math.floor(entry.contentRect.height / rowH))
|
||||||
|
onPageSizeChange(n)
|
||||||
|
})
|
||||||
|
ro.observe(listEl)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
|
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
|
||||||
const [ch, idx] = param
|
const [ch, idx] = param
|
||||||
return longPress(node, {
|
return longPress(node, {
|
||||||
@@ -50,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={scrollEl}>
|
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={listEl}>
|
||||||
{#if loadingChapters && sortedChapters.length === 0}
|
{#if loadingChapters && sortedChapters.length === 0}
|
||||||
{#if viewMode === 'grid'}
|
{#if viewMode === 'grid'}
|
||||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
||||||
@@ -65,15 +77,13 @@
|
|||||||
|
|
||||||
{:else if viewMode === 'grid'}
|
{:else if viewMode === 'grid'}
|
||||||
{#each sortedChapters as ch, i}
|
{#each sortedChapters as ch, i}
|
||||||
{@const inProgress = !ch.read && (ch.lastPageRead ?? 0) > 0}
|
|
||||||
{@const isGridSelected = selectedIds.has(ch.id)}
|
{@const isGridSelected = selectedIds.has(ch.id)}
|
||||||
<button
|
<button
|
||||||
class="grid-cell"
|
class="grid-cell"
|
||||||
class:read={ch.read}
|
class:read={ch.read}
|
||||||
class:in-progress={inProgress}
|
|
||||||
class:grid-selected={isGridSelected}
|
class:grid-selected={isGridSelected}
|
||||||
use:chapterLongPress={[ch, i]}
|
use:chapterLongPress={[ch, i]}
|
||||||
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
|
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, !ch.read && (ch.lastPageRead ?? 0) > 0)}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
|
||||||
title={ch.name}
|
title={ch.name}
|
||||||
>
|
>
|
||||||
@@ -102,7 +112,7 @@
|
|||||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted } }}
|
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted } }}
|
||||||
>
|
>
|
||||||
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
|
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
|
||||||
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
{#if isSelected}<CheckSquare size={15} weight="fill" />{:else}<Square size={15} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
<div class="ch-left">
|
<div class="ch-left">
|
||||||
<span class="ch-name">{ch.name}</span>
|
<span class="ch-name">{ch.name}</span>
|
||||||
@@ -113,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ch-right">
|
<div class="ch-right">
|
||||||
{#if ch.read}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
{#if ch.read}<CheckSquare size={14} weight="light" class="read-icon" />{/if}
|
||||||
{#if ch.downloaded}
|
{#if ch.downloaded}
|
||||||
<div class="ch-dl-wrap">
|
<div class="ch-dl-wrap">
|
||||||
<Download size={13} weight="fill" class="ch-dl-icon" />
|
<Download size={13} weight="fill" class="ch-dl-icon" />
|
||||||
@@ -147,48 +157,51 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ch-list { flex: 1; overflow-y: auto; }
|
.ch-list { flex: 1; overflow: hidden; }
|
||||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
.ch-grid { flex: 1; overflow: hidden; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||||
|
|
||||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
.ch-row { display: flex; align-items: center; padding: 8px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||||
.ch-row:hover { background: var(--bg-raised); }
|
.ch-row:hover { background: var(--bg-raised); }
|
||||||
.ch-row.read { opacity: 0.45; }
|
.ch-row.read { opacity: 0.5; }
|
||||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
:global(.read-icon) { color: var(--text-faint); }
|
:global(.read-icon) { color: var(--text-faint); }
|
||||||
:global(.enqueue-icon) { color: var(--text-faint); }
|
:global(.enqueue-icon) { color: var(--text-faint); }
|
||||||
|
|
||||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
.ch-row:hover .dl-btn { opacity: 1; }
|
|
||||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
|
.dl-btn-delete { color: var(--color-error) !important; }
|
||||||
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
|
||||||
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||||
|
|
||||||
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
|
.ch-dl-wrap { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
|
:global(.ch-dl-icon) { color: var(--text-faint); }
|
||||||
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
|
|
||||||
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
|
|
||||||
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
|
|
||||||
|
|
||||||
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
|
.ch-check {
|
||||||
.ch-row:hover .ch-check { opacity: 1; }
|
display: flex; align-items: center; justify-content: center;
|
||||||
.ch-check-visible { opacity: 1 !important; }
|
width: 20px; height: 20px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm); border: none; background: none;
|
||||||
|
color: var(--text-faint); cursor: pointer; padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-6px);
|
||||||
|
transition: opacity var(--t-fast), transform var(--t-fast), color var(--t-fast);
|
||||||
|
margin-right: -20px;
|
||||||
|
}
|
||||||
|
.ch-row:hover .ch-check { opacity: 1; transform: translateX(0); margin-right: 0; }
|
||||||
|
.ch-check-visible { opacity: 1 !important; transform: translateX(0) !important; margin-right: 0 !important; }
|
||||||
|
.ch-selected .ch-check { color: var(--accent-fg); }
|
||||||
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||||
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
|
|
||||||
|
|
||||||
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
||||||
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.grid-cell-num { font-size: 10px; }
|
.grid-cell-num { font-size: 10px; }
|
||||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--text-faint); }
|
||||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--accent-fg); }
|
||||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||||
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
import type { Chapter, Category } from '$lib/types'
|
import type { Chapter, Category } from '$lib/types'
|
||||||
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
||||||
import { updateSettings } from '$lib/state/settings.svelte'
|
|
||||||
|
|
||||||
interface ContinueChapter {
|
interface ContinueChapter {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
@@ -20,8 +19,6 @@
|
|||||||
sortMode: ChapterSortMode
|
sortMode: ChapterSortMode
|
||||||
sortDir: ChapterSortDir
|
sortDir: ChapterSortDir
|
||||||
viewMode: 'list' | 'grid'
|
viewMode: 'list' | 'grid'
|
||||||
chapterPage: number
|
|
||||||
totalPages: number
|
|
||||||
downloadedCount: number
|
downloadedCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
deletingAll: boolean
|
deletingAll: boolean
|
||||||
@@ -52,11 +49,13 @@
|
|||||||
onSetScanlatorBlacklist: (v: string[]) => void
|
onSetScanlatorBlacklist: (v: string[]) => void
|
||||||
onSetScanlatorForce: (v: boolean) => void
|
onSetScanlatorForce: (v: boolean) => void
|
||||||
onOpenFolder: () => void
|
onOpenFolder: () => void
|
||||||
|
onSortModeChange: (v: ChapterSortMode) => void
|
||||||
|
onSortDirChange: (v: ChapterSortDir) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
chapters, sortedChapters, sortMode, sortDir, viewMode,
|
chapters, sortedChapters, sortMode, sortDir, viewMode,
|
||||||
chapterPage, totalPages, downloadedCount, totalCount, deletingAll,
|
downloadedCount, totalCount, deletingAll,
|
||||||
hasSelection, selectedCount, continueChapter,
|
hasSelection, selectedCount, continueChapter,
|
||||||
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||||
allCategories, mangaCategories, catsLoading, refreshing,
|
allCategories, mangaCategories, catsLoading, refreshing,
|
||||||
@@ -64,7 +63,7 @@
|
|||||||
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
|
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
|
||||||
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
|
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
|
||||||
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
|
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
|
||||||
onOpenFolder,
|
onOpenFolder, onSortModeChange, onSortDirChange,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let sortMenuOpen: boolean = $state(false)
|
let sortMenuOpen: boolean = $state(false)
|
||||||
@@ -166,11 +165,11 @@
|
|||||||
<button
|
<button
|
||||||
class="sort-option"
|
class="sort-option"
|
||||||
class:active={sortMode === val}
|
class:active={sortMode === val}
|
||||||
onclick={() => { updateSettings({ chapterSortMode: val as ChapterSortMode }); onPageChange(1); sortMenuOpen = false }}
|
onclick={() => { onSortModeChange(val as ChapterSortMode); onPageChange(1); sortMenuOpen = false }}
|
||||||
>{label}</button>
|
>{label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="sort-divider"></div>
|
<div class="sort-divider"></div>
|
||||||
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === 'desc' ? 'asc' : 'desc' }); onPageChange(1); sortMenuOpen = false }}>
|
<button class="sort-option" onclick={() => { onSortDirChange(sortDir === 'desc' ? 'asc' : 'desc'); onPageChange(1); sortMenuOpen = false }}>
|
||||||
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
|
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,9 +275,11 @@
|
|||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if downloadedCount > 0}
|
||||||
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
||||||
<FolderOpen size={14} weight="light" />
|
<FolderOpen size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||||
@@ -376,13 +377,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>←</button>
|
|
||||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}>→</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -571,17 +565,6 @@
|
|||||||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
.page-btn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-faint); background: none; cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
|
|
||||||
.sel-count {
|
.sel-count {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||||||
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
|
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
|
||||||
|
|||||||
@@ -8,21 +8,23 @@
|
|||||||
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
||||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
|
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
|
||||||
|
type MenuSeparator = { separator: true }
|
||||||
|
type MenuItem = { label: string; icon?: any; onClick: () => void; danger?: boolean; disabled?: boolean; separator?: never; children?: MenuEntry[] }
|
||||||
|
type MenuEntry = MenuItem | MenuSeparator
|
||||||
import { getManga, getMangaList } from '$lib/request-manager/manga'
|
import { getManga, getMangaList } from '$lib/request-manager/manga'
|
||||||
import { getChapters, fetchChapters, markChapterRead, markChaptersRead, deleteDownloadedChapters } from '$lib/request-manager/chapters'
|
import { markChapterRead, markChaptersRead, deleteDownloadedChapters, fetchChapters } from '$lib/request-manager/chapters'
|
||||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||||
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
|
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
|
||||||
import { saveScroll, getScroll } from '$lib/state/app.svelte'
|
import { saveScroll, getScroll } from '$lib/state/app.svelte'
|
||||||
import { seriesState, openReader, addBookmark,
|
import { seriesState, openReaderForChapter, acknowledgeUpdate, addBookmark, clearMarkersForManga } from '$lib/state/series.svelte'
|
||||||
acknowledgeUpdate, clearMarkersForManga } from '$lib/state/series.svelte'
|
import { updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
import { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte'
|
||||||
import type { MangaPrefs } from '$lib/types/settings'
|
import type { MangaPrefs } from '$lib/types/settings'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { trackingState } from '$lib/state/tracking.svelte'
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
|
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
|
||||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
import { getPref, setPref } from '$lib/state/series.svelte'
|
||||||
import { getPref, setPref } from '$lib/components/series/lib/mangaPrefs'
|
|
||||||
import { openMangaFolder } from '$lib/core/filesystem'
|
import { openMangaFolder } from '$lib/core/filesystem'
|
||||||
import type { Manga, Chapter, Category } from '$lib/types'
|
import type { Manga, Chapter, Category } from '$lib/types'
|
||||||
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
|
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
|
||||||
@@ -35,17 +37,13 @@
|
|||||||
interface Props { mangaId: number }
|
interface Props { mangaId: number }
|
||||||
let { mangaId }: Props = $props()
|
let { mangaId }: Props = $props()
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25
|
let chaptersPerPage: number = $state(25)
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000
|
const MANGA_TTL_MS = 5 * 60 * 1000
|
||||||
const CHAPTER_TTL_MS = 2 * 60 * 1000
|
|
||||||
|
|
||||||
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
|
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
|
||||||
const chapterCache: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map()
|
|
||||||
|
|
||||||
let manga: Manga | null = $state(null)
|
let manga: Manga | null = $state(null)
|
||||||
let chapters: Chapter[] = $state([])
|
|
||||||
let loadingManga: boolean = $state(false)
|
let loadingManga: boolean = $state(false)
|
||||||
let loadingChapters: boolean = $state(true)
|
|
||||||
let enqueueing: Set<number> = $state(new Set())
|
let enqueueing: Set<number> = $state(new Set())
|
||||||
let togglingLibrary: boolean = $state(false)
|
let togglingLibrary: boolean = $state(false)
|
||||||
let chapterPage: number = $state(1)
|
let chapterPage: number = $state(1)
|
||||||
@@ -67,41 +65,27 @@
|
|||||||
let chapterListEl: HTMLDivElement | null = $state(null)
|
let chapterListEl: HTMLDivElement | null = $state(null)
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null
|
let mangaAbort: AbortController | null = null
|
||||||
let chapterAbort: AbortController | null = null
|
|
||||||
let loadingFor: number | null = null
|
|
||||||
let prevChapterIds = new Set<number>()
|
|
||||||
let prevMangaId: number | null = null
|
let prevMangaId: number | null = null
|
||||||
|
|
||||||
const get = <K extends keyof MangaPrefs>(key: K) =>
|
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key)
|
||||||
mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key]
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value)
|
||||||
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
|
|
||||||
if (mangaId) setPref(mangaId, key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const chapters = $derived(seriesState.chaptersFor(mangaId))
|
||||||
|
const loadingChapters = $derived(seriesState.isLoadingChapters(mangaId))
|
||||||
|
const sortedChapters = $derived(seriesState.activeChapterList)
|
||||||
const hasSelection = $derived(selectedIds.size > 0)
|
const hasSelection = $derived(selectedIds.size > 0)
|
||||||
const sortDir = $derived(seriesState.settings.chapterSortDir)
|
|
||||||
const sortMode = $derived(seriesState.settings.chapterSortMode ?? 'source')
|
|
||||||
const scanlatorFilter = $derived((get('scanlatorFilter') ?? []) as string[])
|
|
||||||
const scanlatorBlacklist = $derived((get('scanlatorBlacklist') ?? []) as string[])
|
|
||||||
const scanlatorForce = $derived((get('scanlatorForce') ?? false) as boolean)
|
|
||||||
|
|
||||||
const currentPrefs = $derived({
|
|
||||||
sortMode,
|
|
||||||
sortDir,
|
|
||||||
preferredScanlator: get('preferredScanlator') as string,
|
|
||||||
scanlatorFilter: scanlatorFilter as string[],
|
|
||||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
|
||||||
scanlatorForce: scanlatorForce as boolean,
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableScanlators = $derived(
|
const availableScanlators = $derived(
|
||||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs))
|
const scanlatorFilter = $derived(get('scanlatorFilter') as string[])
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE))
|
const scanlatorBlacklist = $derived(get('scanlatorBlacklist') as string[])
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE))
|
const scanlatorForce = $derived(get('scanlatorForce') as boolean)
|
||||||
|
|
||||||
|
const totalPages = $derived(Math.ceil(sortedChapters.length / chaptersPerPage))
|
||||||
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * chaptersPerPage, chapterPage * chaptersPerPage))
|
||||||
const readCount = $derived(sortedChapters.filter(c => c.read).length)
|
const readCount = $derived(sortedChapters.filter(c => c.read).length)
|
||||||
const totalCount = $derived(sortedChapters.length)
|
const totalCount = $derived(sortedChapters.length)
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0)
|
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0)
|
||||||
@@ -111,17 +95,14 @@
|
|||||||
if (!sortedChapters.length) return null
|
if (!sortedChapters.length) return null
|
||||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
const anyRead = asc.some(c => c.read)
|
const anyRead = asc.some(c => c.read)
|
||||||
const bookmark = mangaId
|
const bookmark = seriesState.bookmarks.find(b => b.mangaId === mangaId)
|
||||||
? seriesState.bookmarks.find(b => b.mangaId === mangaId)
|
|
||||||
: null
|
|
||||||
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
|
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
|
||||||
if (bookmarkedCh && !bookmarkedCh.read) {
|
if (bookmarkedCh && !bookmarkedCh.read)
|
||||||
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber }
|
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: bookmark!.pageNumber }
|
||||||
}
|
|
||||||
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
|
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
|
||||||
const firstUnread = asc.find(c => !c.read)
|
const firstUnread = asc.find(c => !c.read)
|
||||||
const target = inProgress ?? firstUnread
|
const target = inProgress ?? firstUnread
|
||||||
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as const, resumePage: null }
|
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: null }
|
||||||
return { chapter: asc[0], type: 'reread' as const, resumePage: null }
|
return { chapter: asc[0], type: 'reread' as const, resumePage: null }
|
||||||
})())
|
})())
|
||||||
|
|
||||||
@@ -146,17 +127,6 @@
|
|||||||
selectedIds = next
|
selectedIds = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
|
||||||
if (get('autoDownload') && prevChapterIds.size > 0) {
|
|
||||||
const filtered = buildChapterList(nodes, currentPrefs)
|
|
||||||
const newChapters = filtered.filter(c => !prevChapterIds.has(c.id) && !c.downloaded)
|
|
||||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id))
|
|
||||||
}
|
|
||||||
prevChapterIds = new Set(nodes.map(c => c.id))
|
|
||||||
chapters = nodes
|
|
||||||
if (mangaId && nodes.length > 0) checkAndMarkCompleted(mangaId, nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCategories(id: number) {
|
function loadCategories(id: number) {
|
||||||
catsLoading = true
|
catsLoading = true
|
||||||
getCategories()
|
getCategories()
|
||||||
@@ -169,96 +139,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
||||||
if (chaps.length && manga?.status !== 'ONGOING') {
|
if (!chaps.length || manga?.status === 'ONGOING') return
|
||||||
const allRead = chaps.every(c => c.read)
|
const allRead = chaps.every(c => c.read)
|
||||||
const completed = allCategories.find(c => c.name === 'Completed')
|
const completed = allCategories.find(c => c.name === 'Completed')
|
||||||
if (completed) {
|
if (!completed) return
|
||||||
const inCompleted = mangaCategories.some(c => c.id === completed.id)
|
const inCompleted = mangaCategories.some(c => c.id === completed.id)
|
||||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed]
|
if (allRead && !inCompleted) {
|
||||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
await updateMangaCategories(String(id), [completed.id], []).catch(console.error)
|
||||||
}
|
mangaCategories = [...mangaCategories, completed]
|
||||||
|
} else if (!allRead && inCompleted) {
|
||||||
|
await updateMangaCategories(String(id), [], [completed.id]).catch(console.error)
|
||||||
|
mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMangaData(id: number) {
|
function loadMangaData(id: number) {
|
||||||
mangaAbort?.abort()
|
mangaAbort?.abort()
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
mangaAbort = ctrl; loadingFor = id
|
mangaAbort = ctrl
|
||||||
const cached = mangaCache.get(id)
|
const cached = mangaCache.get(id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
manga = cached.data; loadingManga = false
|
manga = cached.data
|
||||||
|
loadingManga = false
|
||||||
|
seriesState.setActiveManga(cached.data)
|
||||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
|
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
|
||||||
getManga(id, ctrl.signal).then(m => {
|
getManga(id, ctrl.signal)
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
.then(m => {
|
||||||
|
if (ctrl.signal.aborted) return
|
||||||
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
||||||
manga = m
|
manga = m
|
||||||
}).catch(() => {})
|
seriesState.setActiveManga(m)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadingManga = true
|
loadingManga = true
|
||||||
getManga(id, ctrl.signal).then(m => {
|
getManga(id, ctrl.signal)
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
.then(m => {
|
||||||
|
if (ctrl.signal.aborted) return
|
||||||
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
mangaCache.set(id, { data: m, fetchedAt: Date.now() })
|
||||||
manga = m
|
manga = m
|
||||||
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false })
|
seriesState.setActiveManga(m)
|
||||||
}
|
|
||||||
|
|
||||||
function loadChaptersData(id: number) {
|
|
||||||
chapterAbort?.abort()
|
|
||||||
const ctrl = new AbortController()
|
|
||||||
chapterAbort = ctrl
|
|
||||||
const cached = chapterCache.get(id)
|
|
||||||
if (cached) {
|
|
||||||
applyChapters(cached.data); loadingChapters = false
|
|
||||||
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return
|
|
||||||
fetchChapters(id, ctrl.signal)
|
|
||||||
.then(() => getChapters(id, ctrl.signal))
|
|
||||||
.then(nodes => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
|
|
||||||
applyChapters(nodes)
|
|
||||||
}).catch(() => {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chapters = []; loadingChapters = true
|
|
||||||
getChapters(id, ctrl.signal).then(nodes => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
applyChapters(nodes); loadingChapters = false
|
|
||||||
return fetchChapters(id, ctrl.signal)
|
|
||||||
.then(() => getChapters(id, ctrl.signal))
|
|
||||||
.then(fresh => {
|
|
||||||
if (ctrl.signal.aborted || loadingFor !== id) return
|
|
||||||
chapterCache.set(id, { data: fresh, fetchedAt: Date.now() })
|
|
||||||
applyChapters(fresh)
|
|
||||||
})
|
})
|
||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false })
|
.catch(() => {})
|
||||||
}
|
.finally(() => { if (!ctrl.signal.aborted) loadingManga = false })
|
||||||
|
|
||||||
async function syncTrackersIntoChapters(id: number, chaps: Chapter[]) {
|
|
||||||
if (!seriesState.settings.trackerSyncBack) return
|
|
||||||
const records = trackingState.recordsFor(id)
|
|
||||||
if (!records.length) return
|
|
||||||
for (const record of records) {
|
|
||||||
try {
|
|
||||||
const { markedIds } = await trackingState.syncFromRemote(id, record, chaps, currentPrefs)
|
|
||||||
if (markedIds.length > 0) {
|
|
||||||
const idSet = new Set(markedIds)
|
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: true } : c)
|
|
||||||
chapterCache.set(id, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = mangaId
|
const id = mangaId
|
||||||
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
|
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
|
||||||
if (id) untrack(() => {
|
untrack(() => {
|
||||||
acknowledgeUpdate(id)
|
acknowledgeUpdate(id)
|
||||||
loadMangaData(id)
|
loadMangaData(id)
|
||||||
loadChaptersData(id)
|
seriesState.loadChapters(id).then(() => {
|
||||||
|
checkAndMarkCompleted(id, seriesState.chaptersFor(id))
|
||||||
|
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id))
|
||||||
|
})
|
||||||
loadCategories(id)
|
loadCategories(id)
|
||||||
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id, chapters))
|
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
if (allMangaForLink.length) {
|
if (allMangaForLink.length) {
|
||||||
autoLinkLibrary(manga, allMangaForLink)
|
autoLinkLibrary(manga, allMangaForLink)
|
||||||
@@ -266,10 +203,7 @@
|
|||||||
} else {
|
} else {
|
||||||
loadingLinkList = true
|
loadingLinkList = true
|
||||||
getMangaList()
|
getMangaList()
|
||||||
.then(list => {
|
.then(list => { allMangaForLink = list; return autoLinkLibrary(manga, list) })
|
||||||
allMangaForLink = list
|
|
||||||
return autoLinkLibrary(manga, list)
|
|
||||||
})
|
|
||||||
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
|
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingLinkList = false })
|
.finally(() => { loadingLinkList = false })
|
||||||
@@ -278,13 +212,9 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
let prevChapterId: number | null = null
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const wasOpen = prevChapterId !== null
|
const wasOpen = seriesState.activeChapter !== null
|
||||||
prevChapterId = seriesState.activeChapter?.id ?? null
|
if (!wasOpen) untrack(() => seriesState.loadChapters(mangaId, { force: true }))
|
||||||
if (wasOpen && !seriesState.activeChapter) {
|
|
||||||
untrack(() => { reloadChapters(mangaId) })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -292,12 +222,33 @@
|
|||||||
if (id === prevMangaId) return
|
if (id === prevMangaId) return
|
||||||
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
|
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
|
||||||
prevMangaId = id
|
prevMangaId = id
|
||||||
if (chapterListEl && id !== null) {
|
if (chapterListEl) chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
|
||||||
chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => () => { mangaAbort?.abort(); chapterAbort?.abort() })
|
$effect(() => () => { mangaAbort?.abort() })
|
||||||
|
|
||||||
|
async function syncTrackersIntoChapters(id: number) {
|
||||||
|
if (!seriesState.settings.trackerSyncBack) return
|
||||||
|
const records = trackingState.recordsFor(id)
|
||||||
|
if (!records.length) return
|
||||||
|
const prefs = {
|
||||||
|
sortMode: seriesState.settings.chapterSortMode,
|
||||||
|
sortDir: seriesState.settings.chapterSortDir,
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter: scanlatorFilter,
|
||||||
|
scanlatorBlacklist: scanlatorBlacklist,
|
||||||
|
scanlatorForce: scanlatorForce,
|
||||||
|
}
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const { markedIds } = await trackingState.syncFromRemote(id, record, seriesState.chaptersFor(id), prefs)
|
||||||
|
if (markedIds.length > 0) {
|
||||||
|
const idSet = new Set(markedIds)
|
||||||
|
seriesState.patchChapters(id, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: true } : c))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return
|
if (!manga) return
|
||||||
@@ -305,23 +256,18 @@
|
|||||||
const next = !manga.inLibrary
|
const next = !manga.inLibrary
|
||||||
await updateManga(manga.id, { inLibrary: next }).catch(console.error)
|
await updateManga(manga.id, { inLibrary: next }).catch(console.error)
|
||||||
manga = { ...manga, inLibrary: next }
|
manga = { ...manga, inLibrary: next }
|
||||||
if (mangaCache.has(manga.id)) { const e = mangaCache.get(manga.id)!; mangaCache.set(manga.id, { ...e, data: manga }) }
|
seriesState.setActiveManga(manga)
|
||||||
|
if (mangaCache.has(manga.id)) mangaCache.set(manga.id, { data: manga, fetchedAt: mangaCache.get(manga.id)!.fetchedAt })
|
||||||
togglingLibrary = false
|
togglingLibrary = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadChapters(id: number) {
|
|
||||||
const nodes = await getChapters(id)
|
|
||||||
chapterCache.set(id, { data: nodes, fetchedAt: Date.now() })
|
|
||||||
applyChapters(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
enqueueing = new Set(enqueueing).add(ch.id)
|
enqueueing = new Set(enqueueing).add(ch.id)
|
||||||
const allowed = await downloadStore.enqueue(ch.id)
|
const allowed = await downloadStore.enqueue(ch.id)
|
||||||
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
|
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
|
||||||
reloadChapters(mangaId)
|
seriesState.loadChapters(mangaId, { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
@@ -331,26 +277,28 @@
|
|||||||
if (!allowed) return
|
if (!allowed) return
|
||||||
}
|
}
|
||||||
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
|
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
|
||||||
reloadChapters(mangaId)
|
seriesState.loadChapters(mangaId, { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
await markChapterRead(chapterId, isRead).catch(console.error)
|
await markChapterRead(chapterId, isRead).catch(console.error)
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, read: isRead } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, read: isRead } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||||
checkAndMarkCompleted(mangaId, chapters)
|
const ch = seriesState.chaptersFor(mangaId).find(c => c.id === chapterId)
|
||||||
const ch = chapters.find(c => c.id === chapterId)
|
const currentPrefs = {
|
||||||
|
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||||
|
}
|
||||||
if (ch) {
|
if (ch) {
|
||||||
if (isRead) await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs)
|
if (isRead) await trackingState.updateFromRead(mangaId, ch, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
else await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
|
else await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
}
|
}
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
if (get('deleteOnRead')) {
|
if (get('deleteOnRead') && ch?.downloaded) {
|
||||||
if (ch?.downloaded) {
|
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
|
||||||
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
|
const doDelete = () => deleteDownloaded(chapterId)
|
||||||
if (delayMs === 0) deleteDownloaded(chapterId)
|
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
||||||
else setTimeout(() => deleteDownloaded(chapterId), delayMs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const ahead = get('downloadAhead') as number
|
const ahead = get('downloadAhead') as number
|
||||||
if (ahead > 0) {
|
if (ahead > 0) {
|
||||||
@@ -367,24 +315,27 @@
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
await markChaptersRead(ids, isRead).catch(console.error)
|
await markChaptersRead(ids, isRead).catch(console.error)
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||||
checkAndMarkCompleted(mangaId, chapters)
|
const currentPrefs = {
|
||||||
|
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
|
||||||
|
preferredScanlator: get('preferredScanlator') as string,
|
||||||
|
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||||
|
}
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
const chaps = seriesState.chaptersFor(mangaId)
|
||||||
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1)
|
const lastRead = [...chaps].sort((a, b) => a.sourceOrder - b.sourceOrder).filter(c => idSet.has(c.id)).at(-1)
|
||||||
if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs)
|
if (lastRead) await trackingState.updateFromRead(mangaId, lastRead, chaps, currentPrefs)
|
||||||
} else {
|
} else {
|
||||||
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
|
await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs)
|
||||||
}
|
}
|
||||||
if (isRead && get('deleteOnRead')) {
|
if (isRead && get('deleteOnRead')) {
|
||||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.downloaded)
|
const toDelete = ids.filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
|
||||||
if (toDelete.length) {
|
if (toDelete.length) {
|
||||||
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
|
const delayMs = (get('deleteDelayHours') as number) * 3_600_000
|
||||||
const doDelete = async () => {
|
const doDelete = async () => {
|
||||||
await deleteDownloadedChapters(toDelete).catch(console.error)
|
await deleteDownloadedChapters(toDelete).catch(console.error)
|
||||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
|
||||||
}
|
}
|
||||||
@@ -392,17 +343,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.downloaded)
|
const ids = [...selectedIds].filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
await deleteDownloadedChapters(ids).catch(console.error)
|
await deleteDownloadedChapters(ids).catch(console.error)
|
||||||
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSelected() {
|
async function downloadSelected() {
|
||||||
await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.downloaded))
|
await enqueueMultiple([...selectedIds].filter(id => !seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded))
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,29 +368,30 @@
|
|||||||
|
|
||||||
async function deleteDownloaded(chapterId: number) {
|
async function deleteDownloaded(chapterId: number) {
|
||||||
await deleteDownloadedChapters([chapterId]).catch(console.error)
|
await deleteDownloadedChapters([chapterId]).catch(console.error)
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, downloaded: false } : c)
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, downloaded: false } : c))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
async function deleteAllDownloads() {
|
||||||
const ids = chapters.filter(c => c.downloaded).map(c => c.id)
|
const ids = seriesState.chaptersFor(mangaId).filter(c => c.downloaded).map(c => c.id)
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
deletingAll = true
|
deletingAll = true
|
||||||
await deleteDownloadedChapters(ids).catch(console.error)
|
await deleteDownloadedChapters(ids).catch(console.error)
|
||||||
chapters = chapters.map(c => ({ ...c, downloaded: false }))
|
seriesState.patchChapters(mangaId, chaps => chaps.map(c => ({ ...c, downloaded: false })))
|
||||||
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
|
|
||||||
deletingAll = false
|
deletingAll = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshChapters() {
|
async function refreshChapters() {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
refreshing = true
|
refreshing = true
|
||||||
chapterCache.delete(mangaId)
|
seriesState.invalidateChapters(mangaId)
|
||||||
fetchChapters(mangaId)
|
fetchChapters(mangaId)
|
||||||
.then(() => reloadChapters(mangaId))
|
.then(() => seriesState.loadChapters(mangaId, { force: true }))
|
||||||
.then(() => addToast({ kind: 'success', title: 'Chapters refreshed', body: `${chapters.length} chapter${chapters.length !== 1 ? 's' : ''} available` }))
|
.then(() => {
|
||||||
|
const count = seriesState.chaptersFor(mangaId).length
|
||||||
|
addToast({ kind: 'success', title: 'Chapters refreshed', body: `${count} chapter${count !== 1 ? 's' : ''} available` })
|
||||||
|
})
|
||||||
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
|
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
|
||||||
.finally(() => refreshing = false)
|
.finally(() => { refreshing = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
|
||||||
@@ -472,57 +423,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
|
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
|
||||||
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
if (inProgress && ch.lastPageRead && ch.lastPageRead > 1) {
|
||||||
const resumePage = inProgress ? ch.lastPageRead ?? null : null
|
|
||||||
const ahead = get('downloadAhead') as number
|
|
||||||
if (ahead > 0) {
|
|
||||||
const idx = ascList.indexOf(ch)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
|
|
||||||
if (toQueue.length) enqueueMultiple(toQueue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inProgress && resumePage && resumePage > 1) {
|
|
||||||
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
|
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
|
||||||
if (!existing || existing.pageNumber < resumePage) {
|
if (!existing || existing.pageNumber < ch.lastPageRead) {
|
||||||
addBookmark({
|
addBookmark({
|
||||||
mangaId,
|
mangaId,
|
||||||
mangaTitle: manga!.title,
|
mangaTitle: manga!.title,
|
||||||
thumbnailUrl: manga!.thumbnailUrl,
|
thumbnailUrl: manga!.thumbnailUrl,
|
||||||
chapterId: ch.id,
|
chapterId: ch.id,
|
||||||
chapterName: ch.name,
|
chapterName: ch.name,
|
||||||
pageNumber: resumePage,
|
pageNumber: ch.lastPageRead,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openReader(ch, ascList, manga)
|
openReaderForChapter(ch, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinue(cc: typeof continueChapter) {
|
interface ContinueChapter { chapter: Chapter; type: 'start' | 'continue' | 'reread'; resumePage: number | null }
|
||||||
if (!cc) return
|
function handleContinue(cc: ContinueChapter) {
|
||||||
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
openReaderForChapter(cc.chapter, manga)
|
||||||
const ahead = get('downloadAhead') as number
|
|
||||||
if (ahead > 0) {
|
|
||||||
const idx = ascList.indexOf(cc.chapter)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id)
|
|
||||||
if (toQueue.length) enqueueMultiple(toQueue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) {
|
|
||||||
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
|
|
||||||
if (!existing || existing.pageNumber < cc.resumePage) {
|
|
||||||
addBookmark({
|
|
||||||
mangaId,
|
|
||||||
mangaTitle: manga!.title,
|
|
||||||
thumbnailUrl: manga!.thumbnailUrl,
|
|
||||||
chapterId: cc.chapter.id,
|
|
||||||
chapterName: cc.chapter.name,
|
|
||||||
pageNumber: cc.resumePage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openReader(cc.chapter, ascList, manga)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
@@ -548,10 +467,10 @@
|
|||||||
async function toggleCategory(cat: Category) {
|
async function toggleCategory(cat: Category) {
|
||||||
const inCat = mangaCategories.some(c => c.id === cat.id)
|
const inCat = mangaCategories.some(c => c.id === cat.id)
|
||||||
try {
|
try {
|
||||||
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
await updateMangaCategories(String(mangaId), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
||||||
if (!inCat && !manga?.inLibrary) {
|
if (!inCat && !manga?.inLibrary) {
|
||||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||||
if (manga) manga = { ...manga, inLibrary: true }
|
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||||
}
|
}
|
||||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
|
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
@@ -561,10 +480,10 @@
|
|||||||
if (!name) return
|
if (!name) return
|
||||||
try {
|
try {
|
||||||
const cat = await createCategoryReq(name)
|
const cat = await createCategoryReq(name)
|
||||||
await updateMangaCategories(mangaId, [cat.id], [])
|
await updateMangaCategories(String(mangaId), [cat.id], [])
|
||||||
if (!manga?.inLibrary) {
|
if (!manga?.inLibrary) {
|
||||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||||
if (manga) manga = { ...manga, inLibrary: true }
|
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||||
}
|
}
|
||||||
allCategories = [...allCategories, cat]
|
allCategories = [...allCategories, cat]
|
||||||
mangaCategories = [...mangaCategories, cat]
|
mangaCategories = [...mangaCategories, cat]
|
||||||
@@ -590,7 +509,7 @@
|
|||||||
{loadingLinkList}
|
{loadingLinkList}
|
||||||
{mangaCategories}
|
{mangaCategories}
|
||||||
{togglingLibrary}
|
{togglingLibrary}
|
||||||
onRead={handleContinue}
|
onRead={(ch) => handleContinue(ch)}
|
||||||
onToggleLibrary={toggleLibrary}
|
onToggleLibrary={toggleLibrary}
|
||||||
onDeleteAll={deleteAllDownloads}
|
onDeleteAll={deleteAllDownloads}
|
||||||
onMigrateOpen={() => migrateOpen = true}
|
onMigrateOpen={() => migrateOpen = true}
|
||||||
@@ -602,15 +521,13 @@
|
|||||||
onGenreClick={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
|
onGenreClick={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="list-wrap">
|
<div class="list-wrap" bind:this={chapterListEl}>
|
||||||
<SeriesActions
|
<SeriesActions
|
||||||
{chapters}
|
{chapters}
|
||||||
{sortedChapters}
|
{sortedChapters}
|
||||||
{sortMode}
|
sortMode={seriesState.settings.chapterSortMode}
|
||||||
{sortDir}
|
sortDir={seriesState.settings.chapterSortDir}
|
||||||
{viewMode}
|
{viewMode}
|
||||||
{chapterPage}
|
|
||||||
{totalPages}
|
|
||||||
{downloadedCount}
|
{downloadedCount}
|
||||||
{totalCount}
|
{totalCount}
|
||||||
{deletingAll}
|
{deletingAll}
|
||||||
@@ -640,6 +557,8 @@
|
|||||||
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
|
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
|
||||||
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
|
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
|
||||||
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
|
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
|
||||||
|
onSortModeChange={(v) => updateSettings({ chapterSortMode: v })}
|
||||||
|
onSortDirChange={(v) => updateSettings({ chapterSortDir: v })}
|
||||||
onOpenFolder={() => manga && openMangaFolder(manga)}
|
onOpenFolder={() => manga && openMangaFolder(manga)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -652,12 +571,12 @@
|
|||||||
{enqueueing}
|
{enqueueing}
|
||||||
{chapterPage}
|
{chapterPage}
|
||||||
{totalPages}
|
{totalPages}
|
||||||
bind:scrollEl={chapterListEl}
|
|
||||||
onOpen={openReaderWithAhead}
|
onOpen={openReaderWithAhead}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onEnqueue={enqueue}
|
onEnqueue={enqueue}
|
||||||
onDeleteDownload={deleteDownloaded}
|
onDeleteDownload={deleteDownloaded}
|
||||||
onPageChange={(p) => chapterPage = p}
|
onPageChange={(p) => chapterPage = p}
|
||||||
|
onPageSizeChange={(n) => { chaptersPerPage = n; chapterPage = Math.min(chapterPage, Math.ceil(sortedChapters.length / n) || 1) }}
|
||||||
{buildCtxItems}
|
{buildCtxItems}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,7 +585,7 @@
|
|||||||
{#if markersOpen && manga}
|
{#if markersOpen && manga}
|
||||||
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
|
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
|
||||||
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
|
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
<MarkersPanel mangaId={manga.id} {chapters} onClose={() => markersOpen = false} />
|
<MarkersPanel mangaId={manga.id} chapters={seriesState.chaptersFor(manga.id)} onClose={() => markersOpen = false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -702,7 +621,7 @@
|
|||||||
{#if migrateOpen && manga}
|
{#if migrateOpen && manga}
|
||||||
<MigrateModal
|
<MigrateModal
|
||||||
{manga}
|
{manga}
|
||||||
currentChapters={chapters}
|
currentChapters={seriesState.chaptersFor(manga.id)}
|
||||||
onClose={() => migrateOpen = false}
|
onClose={() => migrateOpen = false}
|
||||||
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
|
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
import { get } from 'svelte/store'
|
import { get } from 'svelte/store'
|
||||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import { resolvedCover } from '$lib/core/cover/coverResolver'
|
import { resolvedCover } from '$lib/core/cover/coverResolver'
|
||||||
import type { MangaPrefs } from '$lib/types/settings'
|
import type { Manga, Chapter, Category } from '$lib/types'
|
||||||
|
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
import { setPreviewManga } from '$lib/state/series.svelte'
|
import { setPreviewManga } from '$lib/state/series.svelte'
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
|
|
||||||
let manageOpen: boolean = $state(false)
|
let manageOpen: boolean = $state(false)
|
||||||
let genresExpanded: boolean = $state(false)
|
let genresExpanded: boolean = $state(false)
|
||||||
|
let descExpanded: boolean = $state(false)
|
||||||
let altOpen: boolean = $state(false)
|
let altOpen: boolean = $state(false)
|
||||||
|
|
||||||
const statusLabel = $derived(
|
const statusLabel = $derived(
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
||||||
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" />
|
<Thumbnail src={resolvedCover(manga?.id ?? seriesState.activeManga?.id ?? 0, manga?.thumbnailUrl ?? seriesState.activeManga?.thumbnailUrl ?? "")} alt={manga?.title ?? seriesState.activeManga?.title ?? ""} class="cover" id={manga?.id ?? seriesState.activeManga?.id} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<p class="title">{manga?.title}</p>
|
<button class="title" onclick={() => manga && setPreviewManga(manga)} disabled={!manga}>{manga?.title}</button>
|
||||||
|
|
||||||
{#if manga?.author || manga?.artist}
|
{#if manga?.author || manga?.artist}
|
||||||
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
|
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
|
||||||
@@ -148,8 +150,8 @@
|
|||||||
|
|
||||||
{#if manga?.description}
|
{#if manga?.description}
|
||||||
<div class="desc-wrap">
|
<div class="desc-wrap">
|
||||||
<p class="desc">{manga.description}</p>
|
<p class="desc" class:desc-open={descExpanded}>{manga.description}</p>
|
||||||
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
|
<button class="expand-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? 'Show less' : 'Read more'}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +163,7 @@
|
|||||||
<Play size={12} weight="fill" />
|
<Play size={12} weight="fill" />
|
||||||
{continueChapter.type === 'reread' ? 'Read again'
|
{continueChapter.type === 'reread' ? 'Read again'
|
||||||
: continueChapter.type === 'start' ? 'Start reading'
|
: continueChapter.type === 'start' ? 'Start reading'
|
||||||
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ''}`}
|
: `Continue · Ch.${continueChapter.chapter.chapterNumber}`}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -277,7 +279,11 @@
|
|||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
color: var(--text-primary); line-height: var(--leading-snug);
|
color: var(--text-primary); line-height: var(--leading-snug);
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
|
background: none; border: none; padding: 0; text-align: left; cursor: pointer;
|
||||||
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
.title:hover:not(:disabled) { color: var(--accent-fg); }
|
||||||
|
.title:disabled { cursor: default; }
|
||||||
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
|
||||||
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
@@ -328,6 +334,7 @@
|
|||||||
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
|
||||||
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
||||||
.expand-toggle {
|
.expand-toggle {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
|
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
|
||||||
|
|||||||
@@ -65,15 +65,3 @@ export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs
|
|||||||
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
|
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
|
||||||
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReaderChapterList(
|
|
||||||
chapters: Chapter[],
|
|
||||||
prefs: Pick<ChapterDisplayPrefs, 'preferredScanlator' | 'scanlatorFilter'> | undefined,
|
|
||||||
): Chapter[] {
|
|
||||||
return buildChapterList(chapters, {
|
|
||||||
sortMode: 'source',
|
|
||||||
sortDir: 'asc',
|
|
||||||
preferredScanlator: prefs?.preferredScanlator,
|
|
||||||
scanlatorFilter: prefs?.scanlatorFilter,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
|
||||||
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
|
||||||
import type { MangaPrefs } from '$lib/types/settings'
|
|
||||||
|
|
||||||
export { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
|
||||||
|
|
||||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
|
|
||||||
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
|
|
||||||
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
|
||||||
updateSettings({
|
|
||||||
mangaPrefs: {
|
|
||||||
...settingsState.settings.mangaPrefs,
|
|
||||||
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from "phosphor-svelte";
|
import { X } from "phosphor-svelte";
|
||||||
import { getPref, setPref } from "$lib/components/series/lib/mangaPrefs";
|
import { getPref, setPref } from "$lib/state/series.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { libraryState } from "$lib/state/library.svelte";
|
import { libraryState } from "$lib/state/library.svelte";
|
||||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
|
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
|
||||||
import { setPref } from "$lib/components/series/lib/mangaPrefs";
|
import { setPref } from "$lib/state/series.svelte";
|
||||||
import { coverCandidatesSync, dedupeByImage } from "$lib/core/cover/coverResolver";
|
import { coverCandidatesSync, dedupeByImage } from "$lib/core/cover/coverResolver";
|
||||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||||
import type { Manga } from "$lib/types";
|
import type { Manga } from "$lib/types";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
|
||||||
import { seriesState, updateMarker, removeMarker, openReader } from "$lib/state/series.svelte";
|
import { seriesState, updateMarker, removeMarker, openReaderForChapter } from "$lib/state/series.svelte";
|
||||||
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
|
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
const chapter = chapters.find(c => c.id === m.chapterId);
|
const chapter = chapters.find(c => c.id === m.chapterId);
|
||||||
if (!chapter) return;
|
if (!chapter) return;
|
||||||
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
openReader(chapter, chaptersAsc);
|
openReaderForChapter(chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Bug, Lightbulb, X, ArrowSquareOut, ClipboardText, Check, CaretDown, CaretRight } from 'phosphor-svelte'
|
||||||
|
import { platformService } from '$lib/platform-service'
|
||||||
|
import { requestManager } from '$lib/request-manager'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
import type { Settings } from '$lib/types/settings'
|
||||||
|
import {
|
||||||
|
SETTINGS_GROUPS,
|
||||||
|
buildEnvironmentBlock,
|
||||||
|
buildSettingsBlock,
|
||||||
|
buildIssueUrl,
|
||||||
|
type ReportType,
|
||||||
|
type BugFields,
|
||||||
|
type FeatureFields,
|
||||||
|
} from './lib/bugReport'
|
||||||
|
|
||||||
|
interface Props { onClose: () => void }
|
||||||
|
let { onClose }: Props = $props()
|
||||||
|
|
||||||
|
type Step = 'type' | 'compose'
|
||||||
|
|
||||||
|
let step: Step = $state('type')
|
||||||
|
let reportType: ReportType = $state('bug')
|
||||||
|
|
||||||
|
let title = $state('')
|
||||||
|
let description = $state('')
|
||||||
|
let steps = $state('')
|
||||||
|
let expected = $state('')
|
||||||
|
let actual = $state('')
|
||||||
|
let problem = $state('')
|
||||||
|
let solution = $state('')
|
||||||
|
let alternatives = $state('')
|
||||||
|
|
||||||
|
let serverVersion = $state<string | undefined>(undefined)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
requestManager.meta.getAboutServer()
|
||||||
|
.then(s => { serverVersion = s.version })
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
let selectedKeys = $state<Set<keyof Settings>>(new Set())
|
||||||
|
let expandedGroups = $state<Set<string>>(new Set(['Library', 'Reader']))
|
||||||
|
|
||||||
|
function toggleGroup(label: string) {
|
||||||
|
const next = new Set(expandedGroups)
|
||||||
|
next.has(label) ? next.delete(label) : next.add(label)
|
||||||
|
expandedGroups = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroupAll(keys: (keyof Settings)[]) {
|
||||||
|
const next = new Set(selectedKeys)
|
||||||
|
const allOn = keys.every(k => next.has(k))
|
||||||
|
keys.forEach(k => allOn ? next.delete(k) : next.add(k))
|
||||||
|
selectedKeys = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKey(k: keyof Settings) {
|
||||||
|
const next = new Set(selectedKeys)
|
||||||
|
next.has(k) ? next.delete(k) : next.add(k)
|
||||||
|
selectedKeys = next
|
||||||
|
}
|
||||||
|
|
||||||
|
let copied = $state(false)
|
||||||
|
|
||||||
|
function buildPreview(): string {
|
||||||
|
const env = buildEnvironmentBlock(serverVersion)
|
||||||
|
const sets = buildSettingsBlock([...selectedKeys])
|
||||||
|
|
||||||
|
if (reportType === 'bug') {
|
||||||
|
return [
|
||||||
|
`**Description**\n${description || '(empty)'}`,
|
||||||
|
`**Steps to Reproduce**\n${steps || '(empty)'}`,
|
||||||
|
`**Expected**\n${expected || '(empty)'}`,
|
||||||
|
`**Actual**\n${actual || '(empty)'}`,
|
||||||
|
`**Environment**\n${env}`,
|
||||||
|
sets ? `**Relevant Settings**\n\`\`\`yaml\n${sets}\n\`\`\`` : null,
|
||||||
|
].filter(Boolean).join('\n\n')
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
`**Problem / Motivation**\n${problem || '(empty)'}`,
|
||||||
|
`**Proposed Solution**\n${solution || '(empty)'}`,
|
||||||
|
alternatives ? `**Alternatives**\n${alternatives}` : null,
|
||||||
|
`**Environment**\n${env}`,
|
||||||
|
].filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
await navigator.clipboard.writeText(buildPreview())
|
||||||
|
copied = true
|
||||||
|
setTimeout(() => (copied = false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpen() {
|
||||||
|
const settingsBlock = buildSettingsBlock([...selectedKeys])
|
||||||
|
const fields: BugFields | FeatureFields = reportType === 'bug'
|
||||||
|
? { description, steps, expected, actual }
|
||||||
|
: { problem, solution, alternatives }
|
||||||
|
const url = buildIssueUrl(reportType, settingsBlock, title, fields, serverVersion)
|
||||||
|
await platformService.openExternal(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||||
|
|
||||||
|
function formatKeyVal(key: keyof Settings): string {
|
||||||
|
const v = settingsState.settings[key]
|
||||||
|
if (v === undefined || v === null) return '~'
|
||||||
|
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
||||||
|
if (typeof v === 'string') return v === '' ? '""' : v.length > 18 ? v.slice(0, 18) + '…' : v
|
||||||
|
if (typeof v === 'number') return String(v)
|
||||||
|
return '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const envBlock = $derived(buildEnvironmentBlock(serverVersion))
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
reportType === 'bug'
|
||||||
|
? title.trim().length > 0 && description.trim().length > 0
|
||||||
|
: title.trim().length > 0 && problem.trim().length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function autoResize(node: HTMLTextAreaElement) {
|
||||||
|
function resize() {
|
||||||
|
node.style.height = 'auto'
|
||||||
|
node.style.height = node.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
node.addEventListener('input', resize)
|
||||||
|
return { destroy() { node.removeEventListener('input', resize) } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="br-backdrop"
|
||||||
|
role="button" tabindex="-1" aria-label="Close"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && onClose()}>
|
||||||
|
|
||||||
|
<div class="br-shell" role="dialog" aria-label="Report an issue"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
|
<header class="br-header">
|
||||||
|
<div class="br-header-left">
|
||||||
|
{#if reportType === 'bug'}
|
||||||
|
<Bug size={15} weight="duotone" class="br-header-icon bug" />
|
||||||
|
{:else}
|
||||||
|
<Lightbulb size={15} weight="duotone" class="br-header-icon feature" />
|
||||||
|
{/if}
|
||||||
|
<span class="br-title">
|
||||||
|
{step === 'type' ? 'Report an Issue' : reportType === 'bug' ? 'Bug Report' : 'Feature Request'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="br-close" onclick={onClose} aria-label="Close"><X size={14} weight="bold" /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if step === 'type'}
|
||||||
|
<div class="br-type-step">
|
||||||
|
<p class="br-type-hint">What would you like to submit?</p>
|
||||||
|
<div class="br-type-cards">
|
||||||
|
<button class="br-type-card" class:selected={reportType === 'bug'}
|
||||||
|
onclick={() => { reportType = 'bug'; step = 'compose' }}>
|
||||||
|
<Bug size={28} weight="duotone" />
|
||||||
|
<span class="br-type-name">Bug Report</span>
|
||||||
|
<span class="br-type-desc">Something isn't working as expected</span>
|
||||||
|
</button>
|
||||||
|
<button class="br-type-card" class:selected={reportType === 'feature'}
|
||||||
|
onclick={() => { reportType = 'feature'; step = 'compose' }}>
|
||||||
|
<Lightbulb size={28} weight="duotone" />
|
||||||
|
<span class="br-type-name">Feature Request</span>
|
||||||
|
<span class="br-type-desc">Suggest an improvement or new idea</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="br-compose">
|
||||||
|
|
||||||
|
<div class="br-form">
|
||||||
|
<div class="br-form-scroll">
|
||||||
|
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-title">Title <span class="br-required">*</span></label>
|
||||||
|
<input id="br-title" class="br-input" bind:value={title}
|
||||||
|
placeholder={reportType === 'bug' ? 'Short summary of the bug' : 'Short summary of your idea'}
|
||||||
|
maxlength={120} spellcheck />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if reportType === 'bug'}
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-desc">Description <span class="br-required">*</span></label>
|
||||||
|
<textarea id="br-desc" class="br-textarea" bind:value={description}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="What's broken? A clear, concise summary." rows={3} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-steps">Steps to Reproduce</label>
|
||||||
|
<textarea id="br-steps" class="br-textarea" bind:value={steps}
|
||||||
|
use:autoResize
|
||||||
|
placeholder={"1. Open Settings → Library\n2. Enable \"Always show card stats\"\n3. Return to Library\n4. Unread counts are not visible"}
|
||||||
|
rows={4} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="br-field-row">
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-expected">Expected</label>
|
||||||
|
<textarea id="br-expected" class="br-textarea" bind:value={expected}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="What should have happened?" rows={2} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-actual">Actual</label>
|
||||||
|
<textarea id="br-actual" class="br-textarea" bind:value={actual}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="What actually happened?" rows={2} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-problem">Problem / Motivation <span class="br-required">*</span></label>
|
||||||
|
<textarea id="br-problem" class="br-textarea" bind:value={problem}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="What gap or frustration does this address?" rows={3} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-solution">Proposed Solution <span class="br-required">*</span></label>
|
||||||
|
<textarea id="br-solution" class="br-textarea" bind:value={solution}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="What would you like to see?" rows={3} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label" for="br-alt">Alternatives Considered</label>
|
||||||
|
<textarea id="br-alt" class="br-textarea" bind:value={alternatives}
|
||||||
|
use:autoResize
|
||||||
|
placeholder="Any workarounds or other approaches?" rows={2} spellcheck></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="br-field">
|
||||||
|
<label class="br-label">Environment <span class="br-auto">auto-filled</span></label>
|
||||||
|
<pre class="br-env-block">{envBlock}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if reportType === 'bug'}
|
||||||
|
<div class="br-sidebar">
|
||||||
|
<div class="br-sidebar-header">
|
||||||
|
<span class="br-sidebar-title">Include Settings</span>
|
||||||
|
<span class="br-sidebar-hint">Select groups relevant to the bug</span>
|
||||||
|
</div>
|
||||||
|
<div class="br-sidebar-scroll">
|
||||||
|
{#each SETTINGS_GROUPS as group}
|
||||||
|
{@const selectedInGroup = group.keys.filter(k => selectedKeys.has(k)).length}
|
||||||
|
{@const allSelected = selectedInGroup === group.keys.length}
|
||||||
|
{@const expanded = expandedGroups.has(group.label)}
|
||||||
|
|
||||||
|
<div class="br-group">
|
||||||
|
<div class="br-group-row">
|
||||||
|
<button class="br-group-toggle" onclick={() => toggleGroup(group.label)}>
|
||||||
|
{#if expanded}
|
||||||
|
<CaretDown size={9} weight="bold" />
|
||||||
|
{:else}
|
||||||
|
<CaretRight size={9} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
<span class="br-group-label">{group.label}</span>
|
||||||
|
{#if selectedInGroup > 0}
|
||||||
|
<span class="br-group-count">{selectedInGroup}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="br-group-all"
|
||||||
|
class:active={allSelected}
|
||||||
|
onclick={() => selectGroupAll(group.keys)}
|
||||||
|
title={allSelected ? 'Deselect all' : 'Select all'}>
|
||||||
|
{allSelected ? 'none' : 'all'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="br-key-list">
|
||||||
|
{#each group.keys as key}
|
||||||
|
<label class="br-key-row">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={selectedKeys.has(key)}
|
||||||
|
onchange={() => toggleKey(key)}
|
||||||
|
class="br-checkbox" />
|
||||||
|
<span class="br-key-name">{key}</span>
|
||||||
|
<span class="br-key-val">{formatKeyVal(key)}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="br-footer">
|
||||||
|
<button class="br-btn ghost" onclick={() => (step = 'type')}>Back</button>
|
||||||
|
<div class="br-footer-right">
|
||||||
|
<button class="br-btn secondary" onclick={handleCopy} title="Copy report to clipboard">
|
||||||
|
{#if copied}
|
||||||
|
<Check size={13} /><span>Copied!</span>
|
||||||
|
{:else}
|
||||||
|
<ClipboardText size={13} /><span>Copy</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="br-btn primary" disabled={!canSubmit} onclick={handleOpen}>
|
||||||
|
<ArrowSquareOut size={13} /><span>Open on GitHub</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.br-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
z-index: calc(var(--z-settings) + 1);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: br-fade 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes br-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes br-scale { from { transform: scale(0.96); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||||
|
|
||||||
|
.br-shell {
|
||||||
|
width: min(820px, calc(100vw - 32px));
|
||||||
|
height: min(600px, calc(100vh - 64px));
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 32px 80px rgba(0,0,0,0.7);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: br-scale 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.br-header-left { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.br-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||||
|
:global(.br-header-icon.bug) { color: var(--color-error); }
|
||||||
|
:global(.br-header-icon.feature) { color: var(--accent-fg); }
|
||||||
|
.br-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--radius-md); border: none; background: none;
|
||||||
|
color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.br-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.br-type-step {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
gap: var(--sp-5); padding: var(--sp-6);
|
||||||
|
}
|
||||||
|
.br-type-hint {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-faint); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.br-type-cards { display: flex; gap: var(--sp-4); }
|
||||||
|
.br-type-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: var(--sp-2); padding: var(--sp-6) var(--sp-8);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer; color: var(--text-secondary);
|
||||||
|
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.br-type-card:hover { border-color: var(--border-strong); background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.br-type-card.selected { border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.br-type-name { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||||
|
.br-type-desc {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-compose { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||||
|
|
||||||
|
.br-form { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; border-right: 1px solid var(--border-dim); }
|
||||||
|
.br-form-scroll { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
|
||||||
|
.br-field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.br-field-row { display: flex; gap: var(--sp-3); }
|
||||||
|
.br-field-row .br-field { flex: 1; }
|
||||||
|
|
||||||
|
.br-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
.br-required { color: var(--color-error); }
|
||||||
|
.br-auto {
|
||||||
|
font-size: var(--text-2xs); padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent-muted); color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-input, .br-textarea {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
padding: 8px var(--sp-3); outline: none; resize: none;
|
||||||
|
font-family: inherit; line-height: var(--leading-snug);
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.br-input:focus, .br-textarea:focus { border-color: var(--border-focus); }
|
||||||
|
.br-input::placeholder, .br-textarea::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.br-env-block {
|
||||||
|
font-family: monospace; font-size: 11px;
|
||||||
|
color: var(--text-faint); line-height: var(--leading-base);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: var(--sp-3);
|
||||||
|
white-space: pre-wrap; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-sidebar {
|
||||||
|
width: 220px; flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
.br-sidebar-header {
|
||||||
|
padding: var(--sp-3) var(--sp-4) var(--sp-2);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.br-sidebar-title {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
.br-sidebar-hint {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.br-sidebar-scroll { flex: 1; overflow-y: auto; padding: var(--sp-1) 0 var(--sp-2); }
|
||||||
|
|
||||||
|
.br-group { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.br-group-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 3px var(--sp-3) 3px var(--sp-3);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
.br-group-toggle {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: 5px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--text-faint); padding: 2px 0;
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.br-group-toggle:hover { color: var(--text-secondary); }
|
||||||
|
.br-group-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.br-group-count {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
padding: 0 5px; border-radius: var(--radius-full);
|
||||||
|
background: var(--accent-muted); color: var(--accent-fg);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.br-group-all {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--text-faint); padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.br-group-all:hover, .br-group-all.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.br-key-list { display: flex; flex-direction: column; padding-bottom: var(--sp-1); }
|
||||||
|
.br-key-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: 3px var(--sp-3) 3px 24px; cursor: pointer;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.br-key-row:hover { background: var(--bg-raised); }
|
||||||
|
.br-checkbox { accent-color: var(--accent); flex-shrink: 0; cursor: pointer; }
|
||||||
|
.br-key-name {
|
||||||
|
flex: 1; font-family: monospace; font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.br-key-val {
|
||||||
|
font-family: monospace; font-size: 10px;
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
max-width: 52px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-3) var(--sp-5);
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.br-footer-right { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.br-btn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 6px var(--sp-4); border-radius: var(--radius-md);
|
||||||
|
cursor: pointer; border: 1px solid transparent;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base), opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.br-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.br-btn.ghost { background: none; color: var(--text-faint); border-color: transparent; }
|
||||||
|
.br-btn.ghost:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||||
|
.br-btn.secondary { background: none; color: var(--text-muted); border-color: var(--border-dim); }
|
||||||
|
.br-btn.secondary:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||||
|
.br-btn.primary { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
.br-btn.primary:not(:disabled):hover { filter: brightness(1.12); }
|
||||||
|
</style>
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */
|
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */
|
||||||
.s-backdrop {
|
.s-backdrop {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
z-index: var(--z-settings);
|
z-index: var(--z-settings);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
animation: s-fade-in 0.14s ease both;
|
animation: s-fade-in 0.14s ease both;
|
||||||
@@ -29,10 +27,7 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +41,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
border-radius: var(--radius-2xl) 0 0 var(--radius-2xl);
|
border-radius: var(--radius-2xl) 0 0 var(--radius-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte'
|
import { tick } from 'svelte'
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from 'phosphor-svelte'
|
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot, Bug } from 'phosphor-svelte'
|
||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
||||||
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
import ContentSettings from './sections/ContentSettings.svelte'
|
import ContentSettings from './sections/ContentSettings.svelte'
|
||||||
import AboutSettings from './sections/AboutSettings.svelte'
|
import AboutSettings from './sections/AboutSettings.svelte'
|
||||||
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
|
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
|
||||||
|
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||||
|
import BugReporter from './BugReporter.svelte'
|
||||||
|
|
||||||
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
|
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
|
||||||
let { onclose, onOpenThemeEditor }: Props = $props()
|
let { onclose, onOpenThemeEditor }: Props = $props()
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
let tabSlideDir = $state<'up'|'down'>('down')
|
let tabSlideDir = $state<'up'|'down'>('down')
|
||||||
let tabIconKey = $state(0)
|
let tabIconKey = $state(0)
|
||||||
let contentBodyEl: HTMLDivElement
|
let contentBodyEl: HTMLDivElement
|
||||||
|
let bugReporterOpen = $state(false)
|
||||||
|
|
||||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
let listeningKey: keyof Keybinds | null = $state(null)
|
let listeningKey: keyof Keybinds | null = $state(null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey) { e.stopPropagation(); close() } }
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey && !bugReporterOpen) { e.stopPropagation(); close() } }
|
||||||
window.addEventListener('keydown', onKey, true)
|
window.addEventListener('keydown', onKey, true)
|
||||||
return () => window.removeEventListener('keydown', onKey, true)
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
})
|
})
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<ModalBlur />
|
||||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
||||||
@@ -132,6 +136,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="s-sidebar-spacer"></div>
|
||||||
|
<button class="s-nav-item s-bug-btn" class:anims onclick={() => (bugReporterOpen = true)}>
|
||||||
|
<Bug size={14} weight="light" />
|
||||||
|
<span>Report an Issue</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-content">
|
<div class="s-content">
|
||||||
@@ -188,3 +197,17 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if bugReporterOpen}
|
||||||
|
<BugReporter onClose={() => (bugReporterOpen = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.s-sidebar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.s-bug-btn { color: var(--text-faint) !important; }
|
||||||
|
.s-bug-btn:hover {
|
||||||
|
color: var(--color-error) !important;
|
||||||
|
background: var(--color-error-bg) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
import type { Settings } from '$lib/types/settings'
|
||||||
|
|
||||||
|
export type ReportType = 'bug' | 'feature'
|
||||||
|
|
||||||
|
export const SETTINGS_GROUPS: { label: string; keys: (keyof Settings)[] }[] = [
|
||||||
|
{
|
||||||
|
label: 'Library',
|
||||||
|
keys: [
|
||||||
|
'libraryStatsAlways', 'libraryCropCovers', 'libraryPageSize',
|
||||||
|
'libraryBranches', 'libraryShowAllInSaved', 'libraryHideCompletedInSaved',
|
||||||
|
'savedIsDefaultCategory', 'defaultLibraryCategoryId',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reader',
|
||||||
|
keys: [
|
||||||
|
'pageStyle', 'fitMode', 'readingDirection', 'readerZoom',
|
||||||
|
'pageGap', 'optimizeContrast', 'offsetDoubleSpreads',
|
||||||
|
'preloadPages', 'autoMarkRead', 'autoNextChapter',
|
||||||
|
'markReadOnNext', 'autoBookmark', 'autoScroll', 'autoScrollSpeed',
|
||||||
|
'readerDebounceMs', 'readerContainerized', 'barPosition',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
keys: ['serverUrl', 'autoStartServer', 'suwayomiWebUI', 'serverAuthMode'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Appearance',
|
||||||
|
keys: ['theme', 'uiZoom', 'compactSidebar', 'gpuAcceleration', 'qolAnimations', 'systemThemeSync'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Downloads',
|
||||||
|
keys: ['downloadToastsEnabled', 'downloadAutoRetry', 'storageLimitGb', 'serverDownloadsPath'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content & Extensions',
|
||||||
|
keys: ['contentLevel', 'sourceOverridesEnabled', 'preferredExtensionLang', 'flareSolverrEnabled', 'socksProxyEnabled'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Automation',
|
||||||
|
keys: ['automationEnabled', 'automationEnforceGlobal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tracking',
|
||||||
|
keys: ['trackerSyncBack', 'trackerSyncBackThreshold', 'trackerRespectScanlatorFilter'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Performance',
|
||||||
|
keys: ['renderLimit', 'pinchZoom'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const REDACTED = new Set<keyof Settings>([
|
||||||
|
'serverAuthUser', 'serverAuthPass', 'appLockPin',
|
||||||
|
'socksProxyUsername', 'socksProxyPassword',
|
||||||
|
'keybinds', 'customThemes', 'heroSlots', 'mangaLinks', 'mangaPrefs',
|
||||||
|
'libraryTabSort', 'libraryTabStatus', 'libraryTabFilters',
|
||||||
|
'nsfwAllowedSourceIds', 'nsfwBlockedSourceIds',
|
||||||
|
'mangaReaderSettings', 'readerPresets',
|
||||||
|
'hiddenCategoryIds', 'libraryPinnedTabOrder', 'hiddenLibraryTabs',
|
||||||
|
'pinnedSourceIds', 'extraScanDirs',
|
||||||
|
])
|
||||||
|
|
||||||
|
function detectOs(): string {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (ua.includes('Windows NT 10.0')) {
|
||||||
|
const build = ua.match(/Windows NT 10\.0(?:\.(\d+))?/)
|
||||||
|
return build ? `Windows 10/11 (NT ${build[0].replace('Windows ', '')})` : 'Windows 10/11'
|
||||||
|
}
|
||||||
|
if (ua.includes('Windows')) return 'Windows'
|
||||||
|
if (ua.includes('Mac OS X')) {
|
||||||
|
const v = ua.match(/Mac OS X ([\d_]+)/)
|
||||||
|
return v ? `macOS ${v[1].replace(/_/g, '.')}` : 'macOS'
|
||||||
|
}
|
||||||
|
if (ua.includes('Linux')) return 'Linux'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEnvironmentBlock(serverVersion?: string): string {
|
||||||
|
const s = settingsState.settings
|
||||||
|
const host = (() => { try { return new URL(s.serverUrl || '').host } catch { return s.serverUrl || 'unknown' } })()
|
||||||
|
const serverLine = serverVersion
|
||||||
|
? `- Server: Suwayomi ${serverVersion} (${host})`
|
||||||
|
: `- Server: Suwayomi (${host})`
|
||||||
|
return [
|
||||||
|
`- Moku Version: ${appState.version || 'unknown'}`,
|
||||||
|
`- Platform: ${appState.platform}`,
|
||||||
|
`- OS: ${detectOs()}`,
|
||||||
|
serverLine,
|
||||||
|
`- Auth Mode: ${s.serverAuthMode}`,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSettingsBlock(keys: (keyof Settings)[]): string {
|
||||||
|
const s = settingsState.settings
|
||||||
|
return keys
|
||||||
|
.filter(k => !REDACTED.has(k))
|
||||||
|
.map(k => {
|
||||||
|
const v = s[k]
|
||||||
|
if (v === undefined || v === null) return `${k}: ~`
|
||||||
|
if (typeof v === 'string' && v === '') return `${k}: ""`
|
||||||
|
return `${k}: ${JSON.stringify(v)}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BugFields {
|
||||||
|
description: string
|
||||||
|
steps: string
|
||||||
|
expected: string
|
||||||
|
actual: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureFields {
|
||||||
|
problem: string
|
||||||
|
solution: string
|
||||||
|
alternatives: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIssueUrl(
|
||||||
|
type: ReportType,
|
||||||
|
settingsBlock: string,
|
||||||
|
title: string,
|
||||||
|
fields: BugFields | FeatureFields,
|
||||||
|
serverVersion?: string,
|
||||||
|
): string {
|
||||||
|
const base = 'https://github.com/moku-project/Moku/issues/new'
|
||||||
|
|
||||||
|
const prefix = type === 'bug' ? '[Bug] ' : '[Feature Request] '
|
||||||
|
const common = {
|
||||||
|
template: type === 'bug' ? 'bug_report.yml' : 'feature_request.yml',
|
||||||
|
title: title.startsWith(prefix) ? title : `${prefix}${title}`,
|
||||||
|
environment: buildEnvironmentBlock(serverVersion),
|
||||||
|
}
|
||||||
|
|
||||||
|
const specific = type === 'bug'
|
||||||
|
? {
|
||||||
|
description: (fields as BugFields).description,
|
||||||
|
steps: (fields as BugFields).steps,
|
||||||
|
expected: (fields as BugFields).expected,
|
||||||
|
actual: (fields as BugFields).actual,
|
||||||
|
settings: settingsBlock,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
problem: (fields as FeatureFields).problem,
|
||||||
|
solution: (fields as FeatureFields).solution,
|
||||||
|
alternatives: (fields as FeatureFields).alternatives,
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries({ ...common, ...specific })) {
|
||||||
|
if (v !== undefined) merged[k] = v
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(merged)
|
||||||
|
return `${base}?${params.toString()}`
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
let entries = 0, oldest: number | null = null, newest: number | null = null
|
let entries = 0, oldest: number | null = null, newest: number | null = null
|
||||||
const foundKeys: string[] = []
|
const foundKeys: string[] = []
|
||||||
const checkKey = (k: string) => {
|
const checkKey = (k: string) => {
|
||||||
const age = cache.ageOf(k)
|
const age = cache?.ageOf?.(k)
|
||||||
if (age !== undefined) {
|
if (age !== undefined) {
|
||||||
entries++
|
entries++
|
||||||
foundKeys.push(k)
|
foundKeys.push(k)
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
if (newest === null || ts > newest) newest = ts
|
if (newest === null || ts > newest) newest = ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
['library', 'sources', 'popular'].forEach(checkKey)
|
['library', 'sources', 'popular'].forEach(checkKey);
|
||||||
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
||||||
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
||||||
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
||||||
@@ -102,9 +102,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerSplash() {
|
function triggerSplash() {
|
||||||
|
if (appState.devSplash) return
|
||||||
splashTriggered = true
|
splashTriggered = true
|
||||||
setTimeout(() => splashTriggered = false, 200)
|
setTimeout(() => splashTriggered = false, 200)
|
||||||
;(window as any).__mokuShowSplash?.()
|
appState.devSplash = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testWindowsHello() {
|
async function testWindowsHello() {
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
<div class="s-dev-grid">
|
<div class="s-dev-grid">
|
||||||
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
||||||
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
||||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
|
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history?.length ?? 0} entries</span>
|
||||||
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
||||||
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
||||||
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
const name = newFolderName.trim()
|
const name = newFolderName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try {
|
try {
|
||||||
const cat = await getAdapter().createCategory({ name })
|
const cat = await getAdapter().createCategory(name)
|
||||||
categories = [...categories, cat]
|
categories = [...categories, cat]
|
||||||
newFolderName = ''
|
newFolderName = ''
|
||||||
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
|
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
async function commitEdit() {
|
async function commitEdit() {
|
||||||
if (editingId !== null && editingName.trim()) {
|
if (editingId !== null && editingName.trim()) {
|
||||||
try {
|
try {
|
||||||
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
|
await (getAdapter() as any).updateCategory(editingId, { name: editingName.trim() })
|
||||||
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
|
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
|
||||||
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
|
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
async function deleteFolder(id: number) {
|
async function deleteFolder(id: number) {
|
||||||
try {
|
try {
|
||||||
await getAdapter().deleteCategory({ id })
|
await getAdapter().deleteCategory(id)
|
||||||
categories = categories.filter(c => c.id !== id)
|
categories = categories.filter(c => c.id !== id)
|
||||||
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
|
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
const next = !cat[flag]
|
const next = !cat[flag]
|
||||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
|
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
|
||||||
try {
|
try {
|
||||||
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
|
await (getAdapter() as any).updateCategories([id], { [flag]: next ? 'INCLUDE' : 'EXCLUDE' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
|
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
|
||||||
catsError = e?.message ?? 'Failed to update folder'
|
catsError = e?.message ?? 'Failed to update folder'
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
const optimistic = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
|
const optimistic = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
|
||||||
categories = optimistic
|
categories = optimistic
|
||||||
const serverPosition = sToIdx + 1
|
const serverPosition = sToIdx + 1
|
||||||
getAdapter().updateCategoryOrder({ id: fromNumId, position: serverPosition })
|
getAdapter().updateCategoryOrder(fromNumId, serverPosition)
|
||||||
.then((updated: Category[]) => {
|
.then((updated: Category[]) => {
|
||||||
categories = [
|
categories = [
|
||||||
...zeroCat,
|
...zeroCat,
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
{#if isBuiltin || cat}
|
{#if isBuiltin || cat}
|
||||||
<div
|
<div
|
||||||
class="s-folder-row"
|
class="s-folder-row"
|
||||||
|
role="listitem"
|
||||||
class:dragging={dragStrId === id}
|
class:dragging={dragStrId === id}
|
||||||
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
|
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
|
||||||
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
|
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
|
||||||
@@ -205,7 +206,7 @@
|
|||||||
<DotsSixVertical size={14} weight="bold" />
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
||||||
<span class="s-folder-count">{cat?.mangas?.nodes?.length ?? 0} manga</span>
|
<span class="s-folder-count">{cat?.mangas?.length ?? 0} manga</span>
|
||||||
<span class="s-folder-badge">built-in</span>
|
<span class="s-folder-badge">built-in</span>
|
||||||
<div class="s-folder-actions">
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
||||||
@@ -235,16 +236,17 @@
|
|||||||
onblur={commitEdit} use:focusInput />
|
onblur={commitEdit} use:focusInput />
|
||||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="s-folder-identity" draggable="true"
|
<div class="s-folder-identity" role="button" tabindex="0" draggable="true"
|
||||||
ondragstart={(e) => onDragStart(e, id)}
|
ondragstart={(e) => onDragStart(e, id)}
|
||||||
ondragend={onDragEnd}>
|
ondragend={onDragEnd}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && startEdit(cat.id, cat.name)}>
|
||||||
<span class="s-folder-icon">
|
<span class="s-folder-icon">
|
||||||
<FolderSimple size={14} weight="light" />
|
<FolderSimple size={14} weight="light" />
|
||||||
<DotsSixVertical size={14} weight="bold" />
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
|
<button class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
<span class="s-folder-count">{cat.mangas?.length ?? 0} manga</span>
|
||||||
<div class="s-folder-actions">
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon"
|
<button class="s-btn-icon"
|
||||||
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||||
@@ -332,6 +334,8 @@
|
|||||||
.s-folder-icon {
|
.s-folder-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 1px;
|
||||||
}
|
}
|
||||||
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
|
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
|
||||||
.s-folder-icon > :global(*:last-child) { opacity: 0; }
|
.s-folder-icon > :global(*:last-child) { opacity: 0; }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
|
|
||||||
|
const isTauri = platformService.platform === 'tauri'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null
|
selectOpen: string | null
|
||||||
closingSelect: string | null
|
closingSelect: string | null
|
||||||
@@ -67,6 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isTauri}
|
||||||
<label class="s-row">
|
<label class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
|
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||||
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
|
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
|
||||||
@@ -84,6 +87,7 @@
|
|||||||
<span class="s-toggle-thumb"></span>
|
<span class="s-toggle-thumb"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if serverAdvancedOpen}
|
{#if serverAdvancedOpen}
|
||||||
<div class="srv-adv-panel">
|
<div class="srv-adv-panel">
|
||||||
|
|||||||
@@ -83,8 +83,8 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
|
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history?.length ?? 0} entries</span></div>
|
||||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
|
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={!homeState.history?.length}>Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { requestManager } from '$lib/request-manager'
|
import { requestManager } from '$lib/request-manager'
|
||||||
import { authSession, loginUI } from '$lib/core/auth'
|
import { retryBoot } from '$lib/state/boot.svelte'
|
||||||
|
import { authSession, configureAuth } from '$lib/core/auth'
|
||||||
|
|
||||||
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
|
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
|
||||||
let { selectOpen, toggleSelect }: Props = $props()
|
let { selectOpen, toggleSelect }: Props = $props()
|
||||||
@@ -13,9 +14,15 @@
|
|||||||
let secSaved = $state<string | null>(null)
|
let secSaved = $state<string | null>(null)
|
||||||
let secLoaded = $state(false)
|
let secLoaded = $state(false)
|
||||||
|
|
||||||
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
|
function normalizeForUI(mode: string | undefined): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||||
|
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN') return mode
|
||||||
|
return 'NONE'
|
||||||
|
}
|
||||||
|
|
||||||
|
let authMode = $state(normalizeForUI(settingsState.settings.serverAuthMode))
|
||||||
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
|
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
|
||||||
let authPassword = $state('')
|
let authPassword = $state('')
|
||||||
|
let authDirty = $state(false)
|
||||||
|
|
||||||
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
|
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
|
||||||
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
|
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
|
||||||
@@ -31,9 +38,33 @@
|
|||||||
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
||||||
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
||||||
|
|
||||||
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
let lockEnabled = $state(settingsState.settings.appLockEnabled ?? false)
|
||||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
let lockPin = $state(settingsState.settings.appLockEnabled ? (settingsState.settings.appLockPin ?? '') : '')
|
||||||
return 'NONE'
|
let lockPinVis = $state(false)
|
||||||
|
let lockError = $state<string | null>(null)
|
||||||
|
let lockSaved = $state(false)
|
||||||
|
|
||||||
|
function onLockToggle() {
|
||||||
|
lockEnabled = !lockEnabled
|
||||||
|
lockError = null
|
||||||
|
lockSaved = false
|
||||||
|
if (!lockEnabled) {
|
||||||
|
lockPin = ''
|
||||||
|
updateSettings({ appLockEnabled: false, appLockPin: '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLockPinInput() {
|
||||||
|
lockPin = lockPin.replace(/\D/g, '')
|
||||||
|
lockError = null
|
||||||
|
lockSaved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLockPin() {
|
||||||
|
if (lockPin.length < 4) { lockError = 'PIN must be at least 4 digits'; return }
|
||||||
|
updateSettings({ appLockEnabled: true, appLockPin: lockPin })
|
||||||
|
lockSaved = true
|
||||||
|
setTimeout(() => lockSaved = false, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSaved(key: string) {
|
function showSaved(key: string) {
|
||||||
@@ -42,17 +73,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
|
if (!secLoaded) { secLoaded = true; loadServerSecurity() }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadServerSecurity() {
|
async function loadServerSecurity() {
|
||||||
try {
|
try {
|
||||||
const s = await requestManager.extensions.getServerSecurity()
|
const s = await requestManager.extensions.getServerSecurity()
|
||||||
const serverMode = normalizeAuthMode(s.authMode)
|
if (!authDirty) {
|
||||||
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
|
authMode = normalizeForUI(s.authMode)
|
||||||
authMode = serverMode
|
|
||||||
authUsername = s.authUsername || ''
|
authUsername = s.authUsername || ''
|
||||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
|
updateSettings({ serverAuthMode: authMode, serverAuthUser: authUsername })
|
||||||
|
}
|
||||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
|
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
|
||||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
|
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
|
||||||
socksUsername = s.socksProxyUsername
|
socksUsername = s.socksProxyUsername
|
||||||
@@ -66,37 +97,28 @@
|
|||||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (e: any) {
|
||||||
|
console.warn('[SecuritySettings] loadServerSecurity failed:', e?.message ?? e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAuth() {
|
async function saveAuth() {
|
||||||
if (authMode === 'NONE') { await clearAuth(); return }
|
if (authMode === 'NONE') { await clearAuth(); return }
|
||||||
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
|
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
|
||||||
secLoading = true; secError = null
|
secLoading = true; secError = null
|
||||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
|
||||||
try {
|
try {
|
||||||
const newUser = authUsername.trim()
|
const newUser = authUsername.trim()
|
||||||
const newPass = authPassword.trim()
|
const newPass = authPassword.trim()
|
||||||
authSession.clearTokens()
|
|
||||||
if (authMode === 'UI_LOGIN') {
|
|
||||||
await loginUI(newUser, newPass)
|
|
||||||
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
|
|
||||||
} else {
|
|
||||||
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
|
|
||||||
}
|
|
||||||
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
|
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
|
||||||
authPassword = ''
|
|
||||||
showSaved('auth')
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = e?.message ?? 'Failed to save authentication settings'
|
|
||||||
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
|
|
||||||
if (!authMismatch) {
|
|
||||||
authSession.clearTokens()
|
authSession.clearTokens()
|
||||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: authMode === 'BASIC_AUTH' ? newPass : '' })
|
||||||
}
|
configureAuth(settingsState.settings.serverUrl ?? '', authMode as any, newUser, authMode === 'BASIC_AUTH' ? newPass : undefined)
|
||||||
secError = authMismatch
|
authPassword = ''
|
||||||
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
|
authDirty = false
|
||||||
: msg
|
showSaved('auth')
|
||||||
|
retryBoot(authMode as any, newUser, newPass)
|
||||||
|
} catch (e: any) {
|
||||||
|
secError = e?.message ?? 'Failed to save authentication settings'
|
||||||
} finally { secLoading = false }
|
} finally { secLoading = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +127,11 @@
|
|||||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||||
try {
|
try {
|
||||||
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
|
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
|
||||||
|
authSession.clearTokens()
|
||||||
|
configureAuth(settingsState.settings.serverUrl ?? '', 'NONE')
|
||||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||||
authMode = 'NONE'; authUsername = ''; authPassword = ''
|
authMode = 'NONE'; authUsername = ''; authPassword = ''; authDirty = false
|
||||||
authSession.clearTokens(); showSaved('auth')
|
showSaved('auth')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||||
secError = e?.message ?? 'Failed to disable authentication'
|
secError = e?.message ?? 'Failed to disable authentication'
|
||||||
@@ -157,6 +181,7 @@
|
|||||||
authMode = 'NONE'
|
authMode = 'NONE'
|
||||||
authUsername = ''
|
authUsername = ''
|
||||||
authPassword = ''
|
authPassword = ''
|
||||||
|
authDirty = false
|
||||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||||
showSaved('auth')
|
showSaved('auth')
|
||||||
}
|
}
|
||||||
@@ -185,23 +210,28 @@
|
|||||||
<div class="s-segment">
|
<div class="s-segment">
|
||||||
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
|
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
|
||||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
onclick={() => { authMode = opt.value as any; authPassword = ''; authDirty = true }} disabled={secLoading}>{opt.label}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if authMode !== 'NONE'}
|
{#if authMode !== 'NONE'}
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Username</span></div>
|
<div class="s-row-info"><span class="s-label">Username</span></div>
|
||||||
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
<input class="s-input" bind:value={authUsername} oninput={() => authDirty = true} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||||
</div>
|
</div>
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Password</span></div>
|
<div class="s-row-info"><span class="s-label">Password</span></div>
|
||||||
<div class="s-field-wrap">
|
<div class="s-field-wrap">
|
||||||
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} oninput={() => authDirty = true} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||||
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if authMode !== 'NONE' && settingsState.settings.serverAuthMode === authMode && !authPassword}
|
||||||
|
<div class="s-row">
|
||||||
|
<span class="s-desc" style="color: var(--text-muted)">Re-enter your password to update credentials.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
|
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
||||||
@@ -221,8 +251,16 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||||
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
|
disabled={secLoading || (authMode !== 'NONE' && (!authUsername.trim() || !authPassword.trim()))}>
|
||||||
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
|
{#if secLoading}
|
||||||
|
Saving…
|
||||||
|
{:else if secSaved === 'auth'}
|
||||||
|
Saved ✓
|
||||||
|
{:else if authMode === 'NONE'}
|
||||||
|
Save
|
||||||
|
{:else}
|
||||||
|
{authDirty ? 'Enable' : 'Save'}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,6 +321,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">App Lock</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Require PIN on launch</span>
|
||||||
|
<span class="s-desc">Lock the app behind a numeric PIN when it opens</span>
|
||||||
|
</div>
|
||||||
|
<button role="switch" aria-checked={lockEnabled} aria-label="Enable app lock" class="s-toggle" class:on={lockEnabled}
|
||||||
|
onclick={onLockToggle}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
{#if lockEnabled}
|
||||||
|
{#if lockError}
|
||||||
|
<div class="s-banner s-banner-error" style="margin: 0">{lockError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">PIN</span>
|
||||||
|
<span class="s-desc">Minimum 4 digits</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-pin-row">
|
||||||
|
<div class="s-field-wrap">
|
||||||
|
<input class="s-input" type={lockPinVis ? 'text' : 'password'} inputmode="numeric" pattern="\d*"
|
||||||
|
bind:value={lockPin} oninput={onLockPinInput} placeholder="••••" autocomplete="off" spellcheck="false" maxlength="8" />
|
||||||
|
<button class="s-eye-btn" onclick={() => lockPinVis = !lockPinVis} tabindex="-1" aria-label={lockPinVis ? 'Hide PIN' : 'Show PIN'}>{@html lockPinVis ? EyeClose : EyeOpen}</button>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn s-btn-accent" onclick={saveLockPin} disabled={!lockPin}>
|
||||||
|
{lockSaved ? 'Saved ✓' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">FlareSolverr</p>
|
<p class="s-section-title">FlareSolverr</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
@@ -337,4 +410,5 @@
|
|||||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.s-pin-row { display: flex; align-items: center; gap: 8px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||||
import { clearPageCache } from '$lib/request-manager'
|
import { clearPageCache } from '$lib/request-manager'
|
||||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||||
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { requestManager } from '$lib/request-manager'
|
||||||
|
import type { ValidateBackupResult, RestoreStatus } from '$lib/server-adapters/types'
|
||||||
|
|
||||||
const supportsFilesystem = platformService.isSupported('filesystem')
|
const supportsFilesystem = platformService.isSupported('filesystem')
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
platformService.clearMokuCache(),
|
platformService.clearMokuCache(),
|
||||||
platformService.clearSuwayomiCache(),
|
platformService.clearSuwayomiCache(),
|
||||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
getAdapter().clearCachedImages({ cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +171,7 @@
|
|||||||
if (!supportsFilesystem) return
|
if (!supportsFilesystem) return
|
||||||
storageLoading = true; storageError = null
|
storageLoading = true; storageError = null
|
||||||
try {
|
try {
|
||||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
const { downloadsPath: dl, localSourcePath: loc } = await getAdapter().getDownloadsPath()
|
||||||
`{ downloadsPath localSourcePath }`
|
|
||||||
)
|
|
||||||
const dl = pathData.downloadsPath ?? ''
|
|
||||||
const loc = pathData.localSourcePath ?? ''
|
|
||||||
downloadsPathInput = dl; localSourcePathInput = loc
|
downloadsPathInput = dl; localSourcePathInput = loc
|
||||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
@@ -218,8 +217,8 @@
|
|||||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||||
pathsSaving = true
|
pathsSaving = true
|
||||||
try {
|
try {
|
||||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
await getAdapter().setDownloadsPath(dl)
|
||||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
if (loc) await getAdapter().setLocalSourcePath(loc)
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
if (supportsFilesystem && !isExternalServer) {
|
if (supportsFilesystem && !isExternalServer) {
|
||||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||||
@@ -301,8 +300,7 @@
|
|||||||
async function createBackup() {
|
async function createBackup() {
|
||||||
backupLoading = true; backupError = null
|
backupLoading = true; backupError = null
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
const { url } = await getAdapter().createBackup()
|
||||||
const { url } = data.createBackup
|
|
||||||
const name = url.split('/').pop() ?? url
|
const name = url.split('/').pop() ?? url
|
||||||
backupList = [{ url, name }, ...backupList]
|
backupList = [{ url, name }, ...backupList]
|
||||||
await saveBackupList()
|
await saveBackupList()
|
||||||
@@ -313,7 +311,7 @@
|
|||||||
async function deleteBackup(url: string) {
|
async function deleteBackup(url: string) {
|
||||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||||
try {
|
try {
|
||||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
await getAdapter().deleteBackup(url)
|
||||||
backupList = backupList.filter(b => b.url !== url)
|
backupList = backupList.filter(b => b.url !== url)
|
||||||
await saveBackupList()
|
await saveBackupList()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -324,9 +322,7 @@
|
|||||||
|
|
||||||
async function downloadBackup(backup: BackupEntry) {
|
async function downloadBackup(backup: BackupEntry) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
const blob = await getAdapter().downloadBackup(backup.url)
|
||||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
|
||||||
const blob = await resp.blob()
|
|
||||||
if ('showSaveFilePicker' in window) {
|
if ('showSaveFilePicker' in window) {
|
||||||
try {
|
try {
|
||||||
const handle = await (window as any).showSaveFilePicker({
|
const handle = await (window as any).showSaveFilePicker({
|
||||||
@@ -349,12 +345,11 @@
|
|||||||
|
|
||||||
let restoreLoading = $state(false)
|
let restoreLoading = $state(false)
|
||||||
let restoreError = $state<string | null>(null)
|
let restoreError = $state<string | null>(null)
|
||||||
let restoreJobId = $state<string | null>(null)
|
let restoreStatus = $state<RestoreStatus | null>(null)
|
||||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
|
||||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||||
let validateLoading = $state(false)
|
let validateLoading = $state(false)
|
||||||
let validateError = $state<string | null>(null)
|
let validateError = $state<string | null>(null)
|
||||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
let validateResult = $state<ValidateBackupResult | null>(null)
|
||||||
let restoreFile = $state<File | null>(null)
|
let restoreFile = $state<File | null>(null)
|
||||||
|
|
||||||
function stopRestorePoll() {
|
function stopRestorePoll() {
|
||||||
@@ -363,62 +358,19 @@
|
|||||||
|
|
||||||
async function pollRestoreStatus(id: string) {
|
async function pollRestoreStatus(id: string) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
const status = await getAdapter().pollRestoreStatus(id)
|
||||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
|
||||||
{ id }
|
|
||||||
)
|
|
||||||
const status = data.restoreStatus
|
|
||||||
restoreStatus = status
|
restoreStatus = status
|
||||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('operations', JSON.stringify({ query, variables }))
|
|
||||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
|
||||||
form.append('0', file, file.name)
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAuthHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
||||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
|
||||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
|
||||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverUrl(): string {
|
|
||||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
|
||||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
return json.data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitRestore() {
|
async function submitRestore() {
|
||||||
if (!restoreFile) return
|
if (!restoreFile) return
|
||||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
restoreLoading = true; restoreError = null; restoreStatus = null
|
||||||
stopRestorePoll()
|
stopRestorePoll()
|
||||||
try {
|
try {
|
||||||
const form = buildBackupFormData(
|
const result = await requestManager.meta.restoreBackup(restoreFile)
|
||||||
restoreFile,
|
restoreStatus = result.status
|
||||||
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
|
|
||||||
{ backup: null }
|
|
||||||
)
|
|
||||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
|
||||||
const json = await resp.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
const result = json.data.restoreBackup
|
|
||||||
restoreJobId = result.id; restoreStatus = result.status
|
|
||||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||||
@@ -429,15 +381,7 @@
|
|||||||
if (!restoreFile) return
|
if (!restoreFile) return
|
||||||
validateLoading = true; validateError = null; validateResult = null
|
validateLoading = true; validateError = null; validateResult = null
|
||||||
try {
|
try {
|
||||||
const form = buildBackupFormData(
|
validateResult = await requestManager.meta.validateBackup(restoreFile)
|
||||||
restoreFile,
|
|
||||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
|
||||||
{ backup: null }
|
|
||||||
)
|
|
||||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
|
||||||
const json = await resp.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
validateResult = json.data.validateBackup
|
|
||||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||||
finally { validateLoading = false }
|
finally { validateLoading = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
import { toast } from "$lib/state/notifications.svelte";
|
import { toast } from "$lib/state/notifications.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { platformService } from "$lib/platform-service";
|
||||||
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
||||||
import { trackingState } from "$lib/state/tracking.svelte";
|
import { trackingState } from "$lib/state/tracking.svelte";
|
||||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
async function startOAuth(tracker: Tracker) {
|
async function startOAuth(tracker: Tracker) {
|
||||||
if (!tracker.authUrl) return;
|
if (!tracker.authUrl) return;
|
||||||
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
||||||
window.open(tracker.authUrl, "_blank");
|
await platformService.openExternal(tracker.authUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitOAuth() {
|
async function submitOAuth() {
|
||||||
@@ -274,6 +275,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.s-tracker-status-row .s-pill { border-radius: 4px; }
|
||||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||||
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||||
.s-banner-dismissible:hover { opacity: 0.85; }
|
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy, untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
||||||
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
||||||
@@ -16,16 +16,13 @@
|
|||||||
import { addToast } from "$lib/state/notifications.svelte";
|
import { addToast } from "$lib/state/notifications.svelte";
|
||||||
import {
|
import {
|
||||||
seriesState,
|
seriesState,
|
||||||
setPreviewManga, setActiveManga, openReader, addBookmark,
|
setPreviewManga, addBookmark, openReaderForChapter,
|
||||||
} from "$lib/state/series.svelte";
|
} from "$lib/state/series.svelte";
|
||||||
import { app } from "$lib/state/app.svelte";
|
|
||||||
import type { Manga, Chapter, Category } from "$lib/types";
|
import type { Manga, Chapter, Category } from "$lib/types";
|
||||||
|
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte';
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
|
||||||
let loadingDetail = $state(false);
|
let loadingDetail = $state(false);
|
||||||
let loadingChapters = $state(false);
|
|
||||||
let togglingLib = $state(false);
|
let togglingLib = $state(false);
|
||||||
let descExpanded = $state(false);
|
let descExpanded = $state(false);
|
||||||
let folderOpen = $state(false);
|
let folderOpen = $state(false);
|
||||||
@@ -43,8 +40,6 @@
|
|||||||
let loadingLinkList = $state(false);
|
let loadingLinkList = $state(false);
|
||||||
let coverPickerOpen = $state(false);
|
let coverPickerOpen = $state(false);
|
||||||
|
|
||||||
let originNavPage = app.navPage;
|
|
||||||
|
|
||||||
const linkedIds = $derived(
|
const linkedIds = $derived(
|
||||||
seriesState.previewManga
|
seriesState.previewManga
|
||||||
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
|
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
|
||||||
@@ -56,12 +51,15 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? seriesState.previewManga);
|
const displayManga = $derived(manga ?? seriesState.previewManga);
|
||||||
|
const mangaId = $derived(seriesState.previewManga?.id ?? null);
|
||||||
|
const chapters = $derived(mangaId != null ? seriesState.chaptersFor(mangaId) : []);
|
||||||
|
const loadingChapters = $derived(mangaId != null ? seriesState.isLoadingChapters(mangaId) : false);
|
||||||
const totalCount = $derived(chapters.length);
|
const totalCount = $derived(chapters.length);
|
||||||
const readCount = $derived(chapters.filter((c) => c.read).length);
|
const readCount = $derived(chapters.filter((c) => c.read).length);
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
const unreadCount = $derived(totalCount - readCount);
|
||||||
const downloadedCount = $derived(chapters.filter((c) => c.downloaded).length);
|
const downloadedCount = $derived(chapters.filter((c) => c.downloaded).length);
|
||||||
const bookmarkCount = $derived(chapters.filter((c) => c.bookmarked).length);
|
const bookmarkCount = $derived(chapters.filter((c) => c.bookmarked).length);
|
||||||
const inLibrary = $derived(manga?.inLibrary ?? seriesState.previewManga?.inLibrary ?? false);
|
const inLibrary = $derived((manga as Manga | null)?.inLibrary ?? seriesState.previewManga?.inLibrary ?? false);
|
||||||
const scanlators = $derived(
|
const scanlators = $derived(
|
||||||
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
|
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
|
||||||
);
|
);
|
||||||
@@ -100,7 +98,7 @@
|
|||||||
const inProgress = asc.find((c) => !c.read && (c.lastPageRead ?? 0) > 0);
|
const inProgress = asc.find((c) => !c.read && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
||||||
const firstUnread = asc.find((c) => !c.read);
|
const firstUnread = asc.find((c) => !c.read);
|
||||||
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" as const : "start" as const), resumePage: null };
|
||||||
return { ch: asc[0], type: "reread" as const, resumePage: null };
|
return { ch: asc[0], type: "reread" as const, resumePage: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,16 +110,12 @@
|
|||||||
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
let detailAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
detailAbort?.abort();
|
detailAbort?.abort();
|
||||||
chapterAbort?.abort();
|
|
||||||
setPreviewManga(null);
|
setPreviewManga(null);
|
||||||
manga = null; chapters = []; descExpanded = false;
|
manga = null; descExpanded = false;
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +127,14 @@
|
|||||||
linkPickerOpen = true;
|
linkPickerOpen = true;
|
||||||
if (allMangaForLink.length) return;
|
if (allMangaForLink.length) return;
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
getAdapter().getMangaList({})
|
try {
|
||||||
.then((d) => { allMangaForLink = d.items; })
|
const result = await getAdapter().getMangaList({});
|
||||||
.catch(console.error)
|
allMangaForLink = result.items;
|
||||||
.finally(() => { loadingLinkList = false; });
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingLinkList = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; }
|
function closeLinkPicker() { linkPickerOpen = false; }
|
||||||
@@ -145,92 +143,83 @@
|
|||||||
coverPickerOpen = true;
|
coverPickerOpen = true;
|
||||||
if (allMangaForLink.length) return;
|
if (allMangaForLink.length) return;
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
getAdapter().getMangaList({})
|
try {
|
||||||
.then((d) => { allMangaForLink = d.items; })
|
const result = await getAdapter().getMangaList({});
|
||||||
.catch(console.error)
|
allMangaForLink = result.items;
|
||||||
.finally(() => { loadingLinkList = false; });
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingLinkList = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
|
|
||||||
const focal = seriesState.previewManga;
|
const focal = seriesState.previewManga;
|
||||||
if (focal) {
|
if (!focal) return;
|
||||||
originNavPage = app.navPage;
|
|
||||||
load(focal.id);
|
untrack(() => {
|
||||||
|
loadDetail(focal.id);
|
||||||
|
seriesState.loadChapters(focal.id);
|
||||||
loadCategories(focal.id);
|
loadCategories(focal.id);
|
||||||
|
|
||||||
|
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
|
||||||
if (shouldAutoLink) {
|
if (shouldAutoLink) {
|
||||||
if (allMangaForLink.length) {
|
if (allMangaForLink.length) {
|
||||||
autoLinkLibrary(focal, allMangaForLink)
|
autoLinkLibrary(focal, allMangaForLink)
|
||||||
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
.then((n: number) => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
||||||
} else {
|
} else {
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
getAdapter().getMangaList({})
|
(async () => {
|
||||||
.then((d) => {
|
try {
|
||||||
allMangaForLink = d.items;
|
const result = await getAdapter().getMangaList({});
|
||||||
return autoLinkLibrary(focal, d.items);
|
allMangaForLink = result.items;
|
||||||
})
|
const n = await autoLinkLibrary(focal, result.items);
|
||||||
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
|
if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` });
|
||||||
.catch(console.error)
|
} catch (e) {
|
||||||
.finally(() => { loadingLinkList = false; });
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingLinkList = false;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function load(id: number) {
|
async function loadDetail(id: number) {
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort();
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
detailAbort = ctrl;
|
||||||
manga = seriesState.previewManga as Manga;
|
manga = seriesState.previewManga as Manga;
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
descExpanded = false; fetchError = null;
|
||||||
loadingDetail = true; loadingChapters = true;
|
loadingDetail = true;
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
const key = CACHE_KEYS.MANGA(id);
|
||||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>;
|
|
||||||
try {
|
try {
|
||||||
return await getAdapter().fetchManga(String(id), dCtrl.signal);
|
let fullManga: Manga;
|
||||||
|
if (cache.has(key)) {
|
||||||
|
fullManga = await (cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fullManga = await getAdapter().fetchManga(String(id), ctrl.signal);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === "AbortError") throw e;
|
if (e?.name === "AbortError") return;
|
||||||
const local = await getAdapter().getManga(String(id), dCtrl.signal);
|
const local = await getAdapter().getManga(String(id), ctrl.signal);
|
||||||
if (local) return local;
|
if (local) fullManga = local;
|
||||||
throw new Error("Could not load manga details");
|
else throw new Error("Could not load manga details");
|
||||||
}
|
}
|
||||||
})()
|
if (!cache.has(key)) cache.get(key, () => Promise.resolve(fullManga));
|
||||||
.then((fullManga) => {
|
}
|
||||||
if (dCtrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id)))
|
manga = fullManga;
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
} catch (e: any) {
|
||||||
manga = fullManga; loadingDetail = false;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (e?.name === "AbortError") return;
|
if (e?.name === "AbortError") return;
|
||||||
manga = seriesState.previewManga as Manga;
|
manga = seriesState.previewManga as Manga;
|
||||||
fetchError = "Could not load full details — showing cached data";
|
fetchError = "Could not load full details — showing cached data";
|
||||||
loadingDetail = false;
|
} finally {
|
||||||
});
|
if (!ctrl.signal.aborted) loadingDetail = false;
|
||||||
|
|
||||||
getAdapter().getChapters(String(id), cCtrl.signal)
|
|
||||||
.then(async (nodes) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await getAdapter().fetchChapters(String(id), cCtrl.signal);
|
|
||||||
if (!cCtrl.signal.aborted)
|
|
||||||
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cCtrl.signal.aborted) {
|
|
||||||
chapters = sorted;
|
|
||||||
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return;
|
if (!manga) return;
|
||||||
@@ -257,58 +246,68 @@
|
|||||||
|
|
||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
setActiveManga(displayManga);
|
goto(`/series/${displayManga.id}`);
|
||||||
setNavPage(originNavPage);
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategories(mangaId: number) {
|
function handleRead() {
|
||||||
|
if (!continueChapter || !displayManga) return;
|
||||||
|
const { ch, type, resumePage } = continueChapter;
|
||||||
|
if (type === "continue" && resumePage && resumePage > 1) {
|
||||||
|
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
|
||||||
|
if (!existing || existing.pageNumber < resumePage) {
|
||||||
|
addBookmark({
|
||||||
|
mangaId: displayManga.id,
|
||||||
|
mangaTitle: displayManga.title,
|
||||||
|
thumbnailUrl: displayManga.thumbnailUrl,
|
||||||
|
chapterId: ch.id,
|
||||||
|
chapterName: ch.name,
|
||||||
|
pageNumber: resumePage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openReaderForChapter(ch, displayManga);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategories(id: number) {
|
||||||
catsLoading = true;
|
catsLoading = true;
|
||||||
getAdapter().getCategories()
|
getAdapter().getCategories()
|
||||||
.then((cats) => {
|
.then((cats: Category[]) => {
|
||||||
allCategories = cats.filter((c) => c.id !== 0);
|
allCategories = cats.filter((c: Category) => c.id !== 0);
|
||||||
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === mangaId));
|
mangaCategories = allCategories.filter((c: Category) => c.mangas?.some((m: Manga) => m.id === id));
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { catsLoading = false; });
|
.finally(() => { catsLoading = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
|
||||||
const mangaStatus = (manga ?? displayManga)?.status;
|
const isOngoing = (manga ?? displayManga)?.status === "ONGOING";
|
||||||
const isOngoing = mangaStatus === "ONGOING";
|
|
||||||
if (!chaps.length || isOngoing) return;
|
if (!chaps.length || isOngoing) return;
|
||||||
|
|
||||||
const allRead = chaps.every((c) => c.read);
|
const allRead = chaps.every((c) => c.read);
|
||||||
const completed = allCategories.find((c) => c.name === "Completed");
|
const completed = allCategories.find((c) => c.name === "Completed");
|
||||||
if (!completed) return;
|
if (!completed) return;
|
||||||
|
|
||||||
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
||||||
if (allRead && !inCompleted) {
|
if (allRead && !inCompleted) {
|
||||||
await getAdapter().updateMangaCategories(String(mangaId), [completed.id], []).catch(console.error);
|
await getAdapter().updateMangaCategories(String(id), [completed.id], []).catch(console.error);
|
||||||
mangaCategories = [...mangaCategories, completed];
|
mangaCategories = [...mangaCategories, completed];
|
||||||
} else if (!allRead && inCompleted) {
|
} else if (!allRead && inCompleted) {
|
||||||
await getAdapter().updateMangaCategories(String(mangaId), [], [completed.id]).catch(console.error);
|
await getAdapter().updateMangaCategories(String(id), [], [completed.id]).catch(console.error);
|
||||||
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCategory(cat: Category) {
|
async function toggleCategory(cat: Category) {
|
||||||
if (!seriesState.previewManga) return;
|
if (!seriesState.previewManga) return;
|
||||||
const mangaId = seriesState.previewManga.id;
|
const id = seriesState.previewManga.id;
|
||||||
const inCat = mangaCategories.some((c) => c.id === cat.id);
|
const inCat = mangaCategories.some((c) => c.id === cat.id);
|
||||||
await getAdapter().updateMangaCategories(
|
await getAdapter().updateMangaCategories(String(id), inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
|
||||||
String(mangaId),
|
|
||||||
inCat ? [] : [cat.id],
|
|
||||||
inCat ? [cat.id] : [],
|
|
||||||
).catch(console.error);
|
|
||||||
if (!inCat && !inLibrary) {
|
if (!inCat && !inLibrary) {
|
||||||
await getAdapter().addToLibrary(String(mangaId)).catch(console.error);
|
await getAdapter().addToLibrary(String(id)).catch(console.error);
|
||||||
if (manga) manga = { ...manga, inLibrary: true };
|
if (manga) manga = { ...manga, inLibrary: true };
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
}
|
}
|
||||||
mangaCategories = inCat
|
mangaCategories = inCat ? mangaCategories.filter((c) => c.id !== cat.id) : [...mangaCategories, cat];
|
||||||
? mangaCategories.filter((c) => c.id !== cat.id)
|
|
||||||
: [...mangaCategories, cat];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFolderCreate() {
|
async function handleFolderCreate() {
|
||||||
@@ -348,11 +347,11 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
detailAbort?.abort();
|
detailAbort?.abort();
|
||||||
chapterAbort?.abort();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if seriesState.previewManga}
|
{#if seriesState.previewManga}
|
||||||
|
<ModalBlur blur={4} dim={0.72} />
|
||||||
<div
|
<div
|
||||||
class="backdrop"
|
class="backdrop"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -546,24 +545,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if continueChapter}
|
{#if continueChapter}
|
||||||
<button class="read-btn" onclick={() => {
|
<button class="read-btn" onclick={handleRead}>
|
||||||
const { ch, type, resumePage } = continueChapter!;
|
|
||||||
if (type === "continue" && resumePage && resumePage > 1) {
|
|
||||||
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
|
|
||||||
if (!existing || existing.pageNumber < resumePage) {
|
|
||||||
addBookmark({
|
|
||||||
mangaId: displayManga!.id,
|
|
||||||
mangaTitle: displayManga!.title,
|
|
||||||
thumbnailUrl: displayManga!.thumbnailUrl,
|
|
||||||
chapterId: ch.id,
|
|
||||||
chapterName: ch.name,
|
|
||||||
pageNumber: resumePage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openReader(ch, chapters, displayManga);
|
|
||||||
close();
|
|
||||||
}}>
|
|
||||||
<Play size={12} weight="fill" />{continueLabel}
|
<Play size={12} weight="fill" />{continueLabel}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -674,15 +656,11 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.72);
|
|
||||||
z-index: var(--z-settings);
|
z-index: var(--z-settings);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
||||||
animation: fadeIn 0.12s ease both;
|
animation: fadeIn 0.12s ease both;
|
||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
@@ -873,8 +851,8 @@
|
|||||||
.read-btn:hover { filter: brightness(1.1); }
|
.read-btn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
.desc.desc-open { display: block; -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
|
||||||
.desc-toggle {
|
.desc-toggle {
|
||||||
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
|
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
src,
|
src,
|
||||||
|
id = undefined,
|
||||||
alt = "",
|
alt = "",
|
||||||
class: cls = "",
|
class: cls = "",
|
||||||
loading = "lazy",
|
loading = "lazy",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
src: string | null | undefined;
|
src: string | null | undefined;
|
||||||
|
id?: string | number;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
loading?: string;
|
loading?: string;
|
||||||
@@ -27,10 +29,14 @@
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBust(url: string): string {
|
||||||
|
return id != null ? `${url}${url.includes('?') ? '&' : '?'}id=${id}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
function plainThumbUrl(path: string | null | undefined): string {
|
function plainThumbUrl(path: string | null | undefined): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
const base = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
return `${getServerUrl()}${path}`;
|
return withBust(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
@@ -45,11 +51,11 @@
|
|||||||
|
|
||||||
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
||||||
|
|
||||||
const id = ++reqId;
|
const myId = ++reqId;
|
||||||
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
||||||
getBlobUrl(bareUrl, _priority)
|
getBlobUrl(withBust(bareUrl), _priority)
|
||||||
.then(u => { if (id === reqId) blobUrl = u; })
|
.then(u => { if (myId === reqId) blobUrl = u; })
|
||||||
.catch(() => { if (id === reqId) blobUrl = ""; });
|
.catch(() => { if (myId === reqId) blobUrl = ""; });
|
||||||
});
|
});
|
||||||
|
|
||||||
const plainUrl = $derived(plainThumbUrl(src));
|
const plainUrl = $derived(plainThumbUrl(src));
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
blur = 8,
|
||||||
|
dim = 0.6,
|
||||||
|
zIndex = 'var(--z-settings)',
|
||||||
|
animate = true,
|
||||||
|
}: {
|
||||||
|
blur?: number
|
||||||
|
dim?: number
|
||||||
|
zIndex?: string | number
|
||||||
|
animate?: boolean
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal-blur"
|
||||||
|
class:animate
|
||||||
|
style="--blur:{blur}px; --dim:{dim}; --z:{zIndex}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-blur {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
backdrop-filter: blur(var(--blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur));
|
||||||
|
background: rgba(0, 0, 0, var(--dim));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: var(--z);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-blur.animate {
|
||||||
|
animation: blur-in 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blur-in {
|
||||||
|
from { opacity: 0 }
|
||||||
|
to { opacity: 1 }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import { markManyRead } from "$lib/request-manager/chapters";
|
import { markManyRead } from "$lib/request-manager/chapters";
|
||||||
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
|
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
|
||||||
|
|
||||||
let { mangaId, mangaTitle, onClose }: {
|
let { mangaId, mangaTitle, onClose }: {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -250,6 +251,7 @@
|
|||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
|
<ModalBlur blur={4} dim={0.68} />
|
||||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div class="modal" role="dialog" aria-label="Tracking">
|
<div class="modal" role="dialog" aria-label="Tracking">
|
||||||
|
|
||||||
@@ -497,6 +499,7 @@
|
|||||||
{#if confirmUnbindId !== null}
|
{#if confirmUnbindId !== null}
|
||||||
{@const rec = records.find(r => r.id === confirmUnbindId)}
|
{@const rec = records.find(r => r.id === confirmUnbindId)}
|
||||||
{@const trk = rec ? trackerFor(rec.trackerId) : null}
|
{@const trk = rec ? trackerFor(rec.trackerId) : null}
|
||||||
|
<ModalBlur blur={2} dim={0.45} zIndex="calc(var(--z-settings) + 1)" />
|
||||||
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel"
|
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel"
|
||||||
onclick={() => confirmUnbindId = null}
|
onclick={() => confirmUnbindId = null}
|
||||||
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
|
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
|
||||||
@@ -515,10 +518,9 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.68);
|
position: fixed; inset: 0;
|
||||||
z-index: var(--z-settings);
|
z-index: var(--z-settings);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
||||||
animation: fadeIn 0.12s ease both;
|
animation: fadeIn 0.12s ease both;
|
||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
@@ -646,7 +648,7 @@
|
|||||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||||
|
|
||||||
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
|
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
|
||||||
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
|
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
|
||||||
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
|
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
|
||||||
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
|
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
+38
-53
@@ -1,4 +1,5 @@
|
|||||||
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
||||||
|
const SKEW_MS = 60_000 * 2
|
||||||
|
|
||||||
interface AuthConfig {
|
interface AuthConfig {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -10,33 +11,25 @@ interface AuthConfig {
|
|||||||
export interface UiAuthDebugStatus {
|
export interface UiAuthDebugStatus {
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
hasRefreshToken: boolean
|
|
||||||
accessExpiresAt: number | null
|
accessExpiresAt: number | null
|
||||||
refreshExpiresAt: number | null
|
|
||||||
accessExpiresInMs: number | null
|
accessExpiresInMs: number | null
|
||||||
refreshExpiresInMs: number | null
|
|
||||||
shouldRefreshSoon: boolean
|
shouldRefreshSoon: boolean
|
||||||
refreshInFlight: boolean
|
refreshInFlight: boolean
|
||||||
skewMs: number
|
skewMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKEW_MS = 60_000 * 2
|
|
||||||
|
|
||||||
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
|
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
|
||||||
|
|
||||||
let accessToken: string | null = null
|
let accessToken: string | null = null
|
||||||
let refreshToken: string | null = null
|
let refreshToken: string | null = null
|
||||||
let accessExpiresAt: number | null = null
|
let accessExpiresAt: number | null = null
|
||||||
let refreshExpiresAt: number | null = null
|
|
||||||
let refreshInFlight = false
|
let refreshInFlight = false
|
||||||
|
|
||||||
function parseExpiry(token: string): number | null {
|
function parseExpiry(token: string): number | null {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||||
} catch {
|
} catch { return null }
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authSession = {
|
export const authSession = {
|
||||||
@@ -44,26 +37,19 @@ export const authSession = {
|
|||||||
accessToken = null
|
accessToken = null
|
||||||
refreshToken = null
|
refreshToken = null
|
||||||
accessExpiresAt = null
|
accessExpiresAt = null
|
||||||
refreshExpiresAt = null
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUIAccessToken(): string | null {
|
export function getUIAccessToken(): string | null { return accessToken }
|
||||||
return accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
|
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
|
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
|
||||||
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
|
|
||||||
return {
|
return {
|
||||||
mode: config.mode,
|
mode: config.mode,
|
||||||
hasSession: accessToken !== null,
|
hasSession: accessToken !== null,
|
||||||
hasRefreshToken: refreshToken !== null,
|
|
||||||
accessExpiresAt,
|
accessExpiresAt,
|
||||||
refreshExpiresAt,
|
|
||||||
accessExpiresInMs,
|
accessExpiresInMs,
|
||||||
refreshExpiresInMs,
|
|
||||||
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
|
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
|
||||||
refreshInFlight,
|
refreshInFlight,
|
||||||
skewMs: SKEW_MS,
|
skewMs: SKEW_MS,
|
||||||
@@ -77,7 +63,9 @@ export function configureAuth(
|
|||||||
pass?: string,
|
pass?: string,
|
||||||
): void {
|
): void {
|
||||||
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
||||||
authSession.clearTokens()
|
accessToken = null
|
||||||
|
refreshToken = null
|
||||||
|
accessExpiresAt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authHeaders(): Record<string, string> {
|
export function authHeaders(): Record<string, string> {
|
||||||
@@ -90,16 +78,18 @@ export function authHeaders(): Record<string, string> {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> {
|
async function gql<T>(query: string, variables?: Record<string, unknown>, bare = false): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (!bare) Object.assign(headers, authHeaders())
|
||||||
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers,
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||||
return json.data
|
return json.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
||||||
@@ -107,7 +97,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
|||||||
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
const res = await fetch(`${config.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
body: JSON.stringify({ query: '{ settings { authMode } }' }),
|
||||||
})
|
})
|
||||||
if (res.status === 401 || res.status === 403) return 'auth_required'
|
if (res.status === 401 || res.status === 403) return 'auth_required'
|
||||||
if (!res.ok) return 'unreachable'
|
if (!res.ok) return 'unreachable'
|
||||||
@@ -116,17 +106,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
|||||||
/unauthorized|unauthenticated/i.test(e.message)
|
/unauthorized|unauthenticated/i.test(e.message)
|
||||||
)
|
)
|
||||||
return isAuthError ? 'auth_required' : 'ok'
|
return isAuthError ? 'auth_required' : 'ok'
|
||||||
} catch {
|
} catch { return 'unreachable' }
|
||||||
return 'unreachable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
|
||||||
config.user = user
|
|
||||||
config.pass = pass
|
|
||||||
config.mode = 'BASIC_AUTH'
|
|
||||||
const probe = await probeServer()
|
|
||||||
if (probe !== 'ok') throw new Error('Invalid credentials')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGIN_MUTATION = `
|
const LOGIN_MUTATION = `
|
||||||
@@ -145,32 +125,31 @@ const REFRESH_MUTATION = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
|
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
||||||
login: { accessToken: string; refreshToken: string }
|
config.user = user
|
||||||
|
config.pass = pass
|
||||||
|
config.mode = 'BASIC_AUTH'
|
||||||
|
const probe = await probeServer()
|
||||||
|
if (probe !== 'ok') {
|
||||||
|
config.user = prev.user
|
||||||
|
config.pass = prev.pass
|
||||||
|
config.mode = prev.mode as typeof config.mode
|
||||||
|
throw new Error('Invalid credentials')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
|
const data = await gql<{ login: { accessToken: string; refreshToken: string } }>(
|
||||||
|
LOGIN_MUTATION, { username: user, password: pass }, true
|
||||||
|
)
|
||||||
accessToken = data.login.accessToken
|
accessToken = data.login.accessToken
|
||||||
refreshToken = data.login.refreshToken
|
refreshToken = data.login.refreshToken
|
||||||
accessExpiresAt = parseExpiry(accessToken)
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
refreshExpiresAt = parseExpiry(refreshToken)
|
|
||||||
config.mode = 'UI_LOGIN'
|
config.mode = 'UI_LOGIN'
|
||||||
config.user = user
|
config.user = user
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(): Promise<boolean> {
|
|
||||||
if (!refreshToken) return false
|
|
||||||
try {
|
|
||||||
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
|
|
||||||
refreshToken: { accessToken: string }
|
|
||||||
}
|
|
||||||
accessToken = data.refreshToken.accessToken
|
|
||||||
accessExpiresAt = parseExpiry(accessToken)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
if (config.mode !== 'UI_LOGIN') return null
|
if (config.mode !== 'UI_LOGIN') return null
|
||||||
if (!refreshToken) return null
|
if (!refreshToken) return null
|
||||||
@@ -179,8 +158,14 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
|
|||||||
if (refreshInFlight) return accessToken
|
if (refreshInFlight) return accessToken
|
||||||
refreshInFlight = true
|
refreshInFlight = true
|
||||||
try {
|
try {
|
||||||
const ok = await refreshAccessToken()
|
const data = await gql<{ refreshToken: { accessToken: string } }>(
|
||||||
return ok ? accessToken : null
|
REFRESH_MUTATION, { refreshToken }
|
||||||
|
)
|
||||||
|
accessToken = data.refreshToken.accessToken
|
||||||
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
|
return accessToken
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false
|
refreshInFlight = false
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+15
-15
@@ -1,4 +1,4 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { platformService } from "$lib/platform-service";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { getUIAccessToken } from "$lib/core/auth";
|
import { getUIAccessToken } from "$lib/core/auth";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ const inflight = new Map<string, Promise<string>>();
|
|||||||
const MAX_CONCURRENT = 6;
|
const MAX_CONCURRENT = 6;
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let drainScheduled = false;
|
let drainScheduled = false;
|
||||||
let clearing = false;
|
let generation = 0;
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -19,25 +19,24 @@ interface QueueEntry {
|
|||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
const mode = settingsState.serverAuthMode ?? "NONE";
|
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||||
if (mode === "UI_LOGIN") {
|
if (mode === "UI_LOGIN") {
|
||||||
const token = await getUIAccessToken();
|
const token = getUIAccessToken();
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
const user = settingsState.serverAuthUser?.trim() ?? "";
|
const user = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||||
const pass = settingsState.serverAuthPass?.trim() ?? "";
|
const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(url: string): Promise<string> {
|
async function doFetch(url: string, gen: number): Promise<string> {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const res = await tauriFetch(url, { method: "GET", headers });
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
const blob = await platformService.fetchImage(url, headers);
|
||||||
const blob = await res.blob();
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
cache.set(url, blobUrl);
|
cache.set(url, blobUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
@@ -57,8 +56,9 @@ function drain() {
|
|||||||
drainScheduled = false;
|
drainScheduled = false;
|
||||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
const entry = queue.shift()!;
|
const entry = queue.shift()!;
|
||||||
|
const gen = generation;
|
||||||
active++;
|
active++;
|
||||||
doFetch(entry.url)
|
doFetch(entry.url, gen)
|
||||||
.then(entry.resolve, entry.reject)
|
.then(entry.resolve, entry.reject)
|
||||||
.finally(() => { active--; drain(); });
|
.finally(() => { active--; drain(); });
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
|||||||
export function revokeBlobUrl(url: string): void {
|
export function revokeBlobUrl(url: string): void {
|
||||||
const blob = cache.get(url);
|
const blob = cache.get(url);
|
||||||
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
|
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
|
||||||
|
inflight.delete(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deprioritizeQueue(): void {
|
export function deprioritizeQueue(): void {
|
||||||
@@ -125,10 +126,9 @@ export function cancelQueuedFetches(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
clearing = true;
|
generation++;
|
||||||
cancelQueuedFetches();
|
cancelQueuedFetches();
|
||||||
|
inflight.clear();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
inflight.clear();
|
|
||||||
clearing = false;
|
|
||||||
}
|
}
|
||||||
Vendored
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
@@ -90,10 +90,17 @@ export function preloadImage(url: string, useBlob: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearResolvedUrlCache(): void {
|
export function clearResolvedUrlCache(): void {
|
||||||
|
for (const promise of resolvedUrlCache.values()) {
|
||||||
|
promise.then(blobUrl => { if (blobUrl) revokeBlobUrl(blobUrl); }).catch(() => {});
|
||||||
|
}
|
||||||
resolvedUrlCache.clear();
|
resolvedUrlCache.clear();
|
||||||
aspectCache.clear();
|
aspectCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedAspect(url: string): number | undefined {
|
||||||
|
return aspectCache.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPageCache(chapterId?: number): void {
|
export function clearPageCache(chapterId?: number): void {
|
||||||
if (chapterId !== undefined) {
|
if (chapterId !== undefined) {
|
||||||
pageCache.delete(chapterId);
|
pageCache.delete(chapterId);
|
||||||
|
|||||||
+35
-16
@@ -1,4 +1,5 @@
|
|||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import type { Manga } from '$lib/types/manga'
|
import type { Manga } from '$lib/types/manga'
|
||||||
import type { Chapter } from '$lib/types/chapter'
|
import type { Chapter } from '$lib/types/chapter'
|
||||||
|
|
||||||
@@ -10,10 +11,7 @@ const APP_BUTTONS = [
|
|||||||
const FALLBACK_IMAGE = 'moku_logo'
|
const FALLBACK_IMAGE = 'moku_logo'
|
||||||
|
|
||||||
let sessionStart: number | null = null
|
let sessionStart: number | null = null
|
||||||
|
let activeMangaId: number | null = null
|
||||||
function isPublicUrl(url: string | null | undefined): boolean {
|
|
||||||
return typeof url === 'string' && url.startsWith('https://')
|
|
||||||
}
|
|
||||||
|
|
||||||
function trunc(s: string, max = 128): string {
|
function trunc(s: string, max = 128): string {
|
||||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
||||||
@@ -24,34 +22,54 @@ function formatChapter(chapter: Chapter): string {
|
|||||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suwayomi always returns the proxy path (/api/v1/manga/{id}/thumbnail), never the raw CDN URL.
|
||||||
|
// The proxy URL is only useful to Discord when the server is publicly reachable over HTTPS.
|
||||||
|
// For localhost setups cover art falls back to the app logo until Suwayomi exposes rawThumbnailUrl.
|
||||||
|
function resolveCoverUrl(manga: Manga): string {
|
||||||
|
const serverBase = (settingsState.settings.serverUrl ?? '').replace(/\/$/, '')
|
||||||
|
if (!serverBase.startsWith('https://')) return FALLBACK_IMAGE
|
||||||
|
const path = manga.thumbnailUrl?.startsWith('/') ? manga.thumbnailUrl : `/api/v1/manga/${manga.id}/thumbnail`
|
||||||
|
return `${serverBase}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresence(manga: Manga, chapter: Chapter, coverUrl: string) {
|
||||||
|
return {
|
||||||
|
details: trunc(manga.title),
|
||||||
|
state: `${formatChapter(chapter)} · Reading`,
|
||||||
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
|
assets: {
|
||||||
|
largeImage: coverUrl,
|
||||||
|
largeText: trunc(manga.title),
|
||||||
|
smallImage: FALLBACK_IMAGE,
|
||||||
|
smallText: 'Moku',
|
||||||
|
},
|
||||||
|
buttons: APP_BUTTONS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initRpc(): Promise<void> {
|
export async function initRpc(): Promise<void> {
|
||||||
if (!platformService.isSupported('discord-rpc')) return
|
if (!platformService.isSupported('discord-rpc')) return
|
||||||
|
if (!settingsState.settings.discordRpc) return
|
||||||
sessionStart = Date.now()
|
sessionStart = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyRpc(): Promise<void> {
|
export async function destroyRpc(): Promise<void> {
|
||||||
if (!platformService.isSupported('discord-rpc')) return
|
if (!platformService.isSupported('discord-rpc')) return
|
||||||
sessionStart = null
|
sessionStart = null
|
||||||
|
activeMangaId = null
|
||||||
|
await platformService.clearDiscordPresence()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||||
if (!platformService.isSupported('discord-rpc')) return
|
if (!platformService.isSupported('discord-rpc')) return
|
||||||
await platformService.setDiscordPresence({
|
if (!settingsState.settings.discordRpc) return
|
||||||
details: trunc(manga.title),
|
activeMangaId = manga.id
|
||||||
state: `${formatChapter(chapter)} · Reading`,
|
await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga)))
|
||||||
timestamps: { start: sessionStart ?? Date.now() },
|
|
||||||
assets: {
|
|
||||||
largeImage: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE,
|
|
||||||
largeText: trunc(manga.title),
|
|
||||||
smallImage: FALLBACK_IMAGE,
|
|
||||||
smallText: 'Moku',
|
|
||||||
},
|
|
||||||
buttons: APP_BUTTONS,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setIdle(): Promise<void> {
|
export async function setIdle(): Promise<void> {
|
||||||
if (!platformService.isSupported('discord-rpc')) return
|
if (!platformService.isSupported('discord-rpc')) return
|
||||||
|
if (!settingsState.settings.discordRpc) return
|
||||||
await platformService.setDiscordPresence({
|
await platformService.setDiscordPresence({
|
||||||
details: 'Browsing',
|
details: 'Browsing',
|
||||||
timestamps: { start: sessionStart ?? Date.now() },
|
timestamps: { start: sessionStart ?? Date.now() },
|
||||||
@@ -62,5 +80,6 @@ export async function setIdle(): Promise<void> {
|
|||||||
|
|
||||||
export async function clearReading(): Promise<void> {
|
export async function clearReading(): Promise<void> {
|
||||||
if (!platformService.isSupported('discord-rpc')) return
|
if (!platformService.isSupported('discord-rpc')) return
|
||||||
|
if (!settingsState.settings.discordRpc) return
|
||||||
await platformService.clearDiscordPresence()
|
await platformService.clearDiscordPresence()
|
||||||
}
|
}
|
||||||
+32
-23
@@ -1,16 +1,14 @@
|
|||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import type { Manga } from '$lib/types'
|
import type { Manga } from '$lib/types'
|
||||||
|
|
||||||
function sanitizeTitle(title: string): string {
|
function sanitize(s: string): string {
|
||||||
return title.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
return s.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDownloadsRoot(): Promise<string> {
|
function getDownloadsRoot(): string {
|
||||||
let root = (seriesState.settings as any).downloadsPath?.trim() ?? ''
|
return settingsState.settings?.serverDownloadsPath?.trim() ?? ''
|
||||||
if (!root) root = await platformService.getDefaultDownloadsPath().catch(() => '')
|
|
||||||
return root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function join(root: string, ...parts: string[]): string {
|
function join(root: string, ...parts: string[]): string {
|
||||||
@@ -18,31 +16,42 @@ function join(root: string, ...parts: string[]): string {
|
|||||||
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openMangaFolder(manga: Manga): Promise<void> {
|
function checkSupported(): boolean {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!platformService.isSupported('filesystem')) {
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
const root = await getDownloadsRoot()
|
return true
|
||||||
if (!root) return
|
|
||||||
await platformService.openPath(join(root, 'mangas', sanitizeTitle(manga.title))).catch(console.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCustomFolder(path: string): Promise<void> {
|
function checkRoot(root: string): boolean {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!root) {
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' })
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (!path?.trim()) return
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMangaFolder(manga: Manga): Promise<void> {
|
||||||
|
if (!checkSupported()) return
|
||||||
|
const root = getDownloadsRoot()
|
||||||
|
if (!checkRoot(root)) return
|
||||||
|
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
|
||||||
|
const path = source
|
||||||
|
? join(root, 'mangas', sanitize(source), sanitize(manga.title))
|
||||||
|
: join(root, 'mangas', sanitize(manga.title))
|
||||||
await platformService.openPath(path).catch(console.error)
|
await platformService.openPath(path).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDownloadsFolder(): Promise<void> {
|
export async function openDownloadsFolder(): Promise<void> {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!checkSupported()) return
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
const root = getDownloadsRoot()
|
||||||
return
|
if (!checkRoot(root)) return
|
||||||
|
await platformService.openPath(root).catch(console.error)
|
||||||
}
|
}
|
||||||
const root = await getDownloadsRoot()
|
|
||||||
if (!root) return
|
export async function openCustomFolder(path: string): Promise<void> {
|
||||||
await platformService.openPath(join(root, 'mangas')).catch(console.error)
|
if (!checkSupported()) return
|
||||||
|
if (!path?.trim()) return
|
||||||
|
await platformService.openPath(path).catch(console.error)
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,9 @@ interface StoredVault {
|
|||||||
data: string
|
data: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function toB64(buf: ArrayBuffer): string {
|
function toB64(buf: ArrayBuffer | Uint8Array): string {
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromB64(s: string): Uint8Array {
|
function fromB64(s: string): Uint8Array {
|
||||||
@@ -29,7 +30,7 @@ async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
|||||||
const enc = new TextEncoder()
|
const enc = new TextEncoder()
|
||||||
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey'])
|
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey'])
|
||||||
return crypto.subtle.deriveKey(
|
return crypto.subtle.deriveKey(
|
||||||
{ name: 'PBKDF2', salt, iterations: SALT_ITERATIONS, hash: 'SHA-256' },
|
{ name: 'PBKDF2', salt: salt.slice(), iterations: SALT_ITERATIONS, hash: 'SHA-256' },
|
||||||
keyMat,
|
keyMat,
|
||||||
{ name: 'AES-GCM', length: 256 },
|
{ name: 'AES-GCM', length: 256 },
|
||||||
false,
|
false,
|
||||||
@@ -74,11 +75,11 @@ export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
|||||||
if (!stored) return null
|
if (!stored) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = await deriveKey(pin, fromB64(stored.salt))
|
const key = await deriveKey(pin, fromB64(stored.salt).slice())
|
||||||
const plain = await crypto.subtle.decrypt(
|
const plain = await crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv: fromB64(stored.iv) },
|
{ name: 'AES-GCM', iv: fromB64(stored.iv).slice() },
|
||||||
key,
|
key,
|
||||||
fromB64(stored.data),
|
fromB64(stored.data).slice(),
|
||||||
)
|
)
|
||||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload
|
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user