Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 |
@@ -0,0 +1,171 @@
|
|||||||
|
name: Build Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: Build frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Linux x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libfuse2
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Linux x64)
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \
|
||||||
|
-o suwayomi-linux.tar.gz
|
||||||
|
|
||||||
|
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c -
|
||||||
|
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
||||||
|
JAVA="suwayomi-extracted/jre/bin/java"
|
||||||
|
CATCH="suwayomi-extracted/bin/catch_abort.so"
|
||||||
|
|
||||||
|
for f in "$JAR" "$JAVA" "$CATCH"; do
|
||||||
|
if [ ! -e "$f" ]; then
|
||||||
|
echo "ERROR: expected file not found: $f"
|
||||||
|
find suwayomi-extracted -type f | head -40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
|
||||||
|
|
||||||
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||||
|
|
||||||
|
- name: Stage Linux launcher sidecar
|
||||||
|
run: |
|
||||||
|
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
|
||||||
|
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||||
|
env:
|
||||||
|
NO_STRIP: "true"
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||||
|
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||||
|
|
||||||
|
[ -n "$APPIMAGE" ] && upload_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
||||||
|
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
|
||||||
@@ -7,6 +7,9 @@ on:
|
|||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.4.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
@@ -40,9 +43,6 @@ jobs:
|
|||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -138,7 +138,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for aarch64
|
- name: Swap bundle for aarch64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
@@ -148,16 +147,8 @@ jobs:
|
|||||||
- 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: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
env:
|
env:
|
||||||
# Ad-hoc signing ("-") ships without a Developer ID.
|
|
||||||
# Gatekeeper will quarantine the app on other Macs — users must run:
|
|
||||||
# xattr -rd com.apple.quarantine Moku.app
|
|
||||||
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
|
||||||
# "Developer ID Application: ..." cert name and add
|
|
||||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
|
||||||
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for x86_64
|
- name: Swap bundle for x86_64
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
@@ -169,17 +160,35 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── upload artifacts ───────────────────────────────────────────────────
|
- name: Upload macOS artifacts to release
|
||||||
- name: Upload arm64 .dmg
|
env:
|
||||||
uses: actions/upload-artifact@v4
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
run: |
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
# Wait for the Windows workflow to have created the draft release
|
||||||
retention-days: 7
|
for i in $(seq 1 12); do
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload x64 .dmg
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
uses: actions/upload-artifact@v4
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
with:
|
exit 1
|
||||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
fi
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
|
||||||
retention-days: 7
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
|
||||||
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -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:
|
||||||
@@ -134,12 +134,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
echo "Deleting existing draft release $RELEASE_ID"
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
# Also delete the tag so tauri-action can recreate it
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
echo "Deleted draft release and tag"
|
echo "Deleted draft release and tag"
|
||||||
else
|
else
|
||||||
echo "No existing draft release found"
|
echo "No existing draft release found"
|
||||||
@@ -149,14 +152,16 @@ jobs:
|
|||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
with:
|
||||||
tagName: v${{ github.event.inputs.version }}
|
tagName: v${{ github.event.inputs.version }}
|
||||||
releaseName: Moku v${{ github.event.inputs.version }}
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
Moku v${{ github.event.inputs.version }}
|
||||||
Download the `.exe` file below to install or update.
|
|
||||||
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** Download `moku.flatpak`
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.5.0
|
pkgver=0.9.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Youwes09/Moku"
|
url="https://github.com/moku-project/Moku"
|
||||||
license=('Apache 2.0')
|
license=('Apache-2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -18,13 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
|
||||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
|
||||||
)
|
)
|
||||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
sha256sums=(
|
||||||
|
'SKIP'
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
)
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -34,7 +34,6 @@ prepare() {
|
|||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -46,10 +45,7 @@ package() {
|
|||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
@@ -66,7 +62,7 @@ server.maxSourcesInParallel = 6
|
|||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
@@ -90,7 +86,7 @@ unset WAYLAND_DISPLAY
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
exec /usr/lib/moku/jre/bin/java \
|
exec java \
|
||||||
-Djava.awt.headless=true \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
@@ -99,16 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
|
|||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/moku-project/Moku/commits/main)
|
||||||
[](./LICENSE)
|
[](https://github.com/moku-project/Moku)
|
||||||
[](https://discord.gg/x97hj8zR72)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,16 +20,20 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
|
||||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="docs/screenshots">View all screenshots →</a>
|
<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" style="color: #a8c4a8;">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -43,7 +47,6 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
|
||||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
@@ -54,36 +57,55 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Flatpak (Linux, recommended)
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**winget:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Moku.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
|
|
||||||
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flatpak install moku.flatpak
|
flatpak install io.github.moku_app.Moku
|
||||||
flatpak run dev.moku.app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install moku.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
### Nix
|
### Nix
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/Moku
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/Moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
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:
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
> ```bash
|
> ```bash
|
||||||
@@ -105,7 +127,7 @@ You can point Moku at any Suwayomi instance — local or remote — via **Settin
|
|||||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Youwes09/Moku
|
git clone https://github.com/moku-project/Moku
|
||||||
cd Moku
|
cd Moku
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
pnpm tauri:dev
|
||||||
@@ -136,7 +158,7 @@ pnpm tauri:dev
|
|||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
[](https://discord.gg/x97hj8zR72)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
Major Revisions:
|
Major Revisions:
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
|
- Moku-Share allows exporting of Manga
|
||||||
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
Minor Revisions:
|
Minor Revisions:
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
@@ -8,16 +12,12 @@ Minor Revisions:
|
|||||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Fix Library-Refresh System (TESTING)
|
- Fix Library-Refresh System (TESTING)
|
||||||
|
|
||||||
General/Misc Bugs:
|
- Suwayomi RESET
|
||||||
- Fix Highlightable Elements
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
- Investigate "egl:failed to create dri2 screen"
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
- Check Fonts/Design on Flatpak
|
|
||||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
|
||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
|
||||||
|
|
||||||
|
|
||||||
In-Progress:
|
In-Progress:
|
||||||
@@ -25,26 +25,15 @@ In-Progress:
|
|||||||
- Working on 3D Display Cards
|
- Working on 3D Display Cards
|
||||||
- Add Flathub Support (Pending Video)
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
- QOL Animations & Revamps
|
|
||||||
- Extensions QOL Animations
|
|
||||||
- Folders Slide
|
|
||||||
- Dropdown Formatting (Repositories, Etc)
|
|
||||||
- Extensions Revamps
|
|
||||||
- Fix Pill-Shaped Language Filter
|
|
||||||
- Fix ALL ALL EN Tag Issue
|
|
||||||
- Search QOL Animations
|
|
||||||
- Languages Dropdown Animations
|
|
||||||
- Search Revamps
|
|
||||||
- Custom Language Selector Modal
|
|
||||||
- Change Tab Selector to match Extensions & Library Folders (Design)
|
|
||||||
- Filter Genre should Filter Tags as well
|
|
||||||
- Tracking Revamp
|
|
||||||
- Completely Revamp Tracking
|
|
||||||
|
|
||||||
- Fix Search Folder Tabs (Right-Align)
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
|
- Tracking
|
||||||
|
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
|
||||||
|
|
||||||
|
- Hide Completed from Library Settting
|
||||||
|
|
||||||
|
|
||||||
Testing Bugs:
|
Notes from last time:
|
||||||
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
|
- Currently working on #42, just need to mount panel and fix button in reader
|
||||||
- Fix Library Folders (Uneven Padding + Bleed into Other Folders); Appears Constraints are Off
|
|
||||||
-
|
|
||||||
|
Before Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 940 KiB |
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.8.0";
|
version = "0.9.1";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -149,43 +149,27 @@ EOF
|
|||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
bumpScript = pkgs.writeShellApplication {
|
||||||
name = "moku-bump";
|
name = "moku-bump";
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
|
||||||
|
nodejs_22 pnpm
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
|
||||||
text = ''
|
text = ''
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
echo "── Bumping version fields to $VERSION ──"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
"$REPO/flake.nix"
|
"$REPO/flake.nix"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating Cargo.lock ──"
|
||||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
echo "Bumped to $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
flatpakScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-flatpak";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
gnused coreutils git
|
|
||||||
nodejs_22 pnpm
|
|
||||||
appstream flatpak-builder flatpak
|
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
|
||||||
];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Done"
|
echo "Done"
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
echo "── Building frontend ──"
|
||||||
@@ -199,7 +183,15 @@ EOF
|
|||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
echo "sha256: $FRONTEND_SHA"
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
echo "── Patching manifest sha256 ──"
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Patching flatpak manifest (version + frontend sha256) ──"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
import re, sys
|
import re, sys
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
@@ -213,29 +205,70 @@ EOF
|
|||||||
PYEOF
|
PYEOF
|
||||||
echo "Done"
|
echo "Done"
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
echo ""
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
echo "Bumped to v$VERSION"
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
echo ""
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
echo "Commit field in the flatpak manifest still points to the old tag."
|
||||||
|
echo "After pushing the tag, run:"
|
||||||
|
echo " nix run .#post-tag-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
postTagBumpScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-post-tag-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
echo "── Resolving commit for v$VERSION ──"
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
||||||
|
| awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
echo "commit: $COMMIT"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
echo "Done"
|
echo "Done"
|
||||||
|
|
||||||
echo "── Building flatpak ──"
|
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "post-tag-bump complete for v$VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpakScript = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused coreutils git
|
||||||
|
appstream flatpak-builder flatpak
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
|
||||||
|
echo "── Building flatpak for v$VERSION ──"
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
flatpak-builder \
|
flatpak-builder \
|
||||||
--repo="$REPO/repo" \
|
--repo="$REPO/repo" \
|
||||||
--force-clean \
|
--force-clean \
|
||||||
"$REPO/build-dir" \
|
"$REPO/build-dir" \
|
||||||
"$MANIFEST"
|
"$MANIFEST"
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
echo "moku.flatpak created"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done — v$VERSION"
|
echo "moku.flatpak created — v$VERSION"
|
||||||
echo " -> $REPO/moku.flatpak"
|
|
||||||
echo ""
|
|
||||||
echo "After pushing the tag, run:"
|
|
||||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,7 +282,7 @@ EOF
|
|||||||
PKGBUILD="$REPO/PKGBUILD"
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||||
|
|
||||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
echo "Fetching tarball sha256..."
|
echo "Fetching tarball sha256..."
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
|
||||||
@@ -279,6 +312,7 @@ EOF
|
|||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||||
|
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||||
@@ -300,6 +334,7 @@ EOF
|
|||||||
suwayomi-server
|
suwayomi-server
|
||||||
cloudflared
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
@@ -308,10 +343,11 @@ EOF
|
|||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release:"
|
echo "Release workflow:"
|
||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha"
|
||||||
|
echo " nix run .#flatpak -- <ver> build moku.flatpak"
|
||||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: io.github.Youwes09.Moku
|
app-id: io.github.moku_project.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -171,19 +171,19 @@ modules:
|
|||||||
- 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 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 cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/Youwes09/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.8.0
|
tag: v0.9.1
|
||||||
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
@@ -83,19 +83,6 @@
|
|||||||
"dest": "cargo/vendor/anyhow-1.0.102",
|
"dest": "cargo/vendor/anyhow-1.0.102",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/arbitrary/arbitrary-1.4.2.crate",
|
|
||||||
"sha256": "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1",
|
|
||||||
"dest": "cargo/vendor/arbitrary-1.4.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/arbitrary-1.4.2",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -411,14 +398,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/cc/cc-1.2.60.crate",
|
"url": "https://static.crates.io/crates/cc/cc-1.2.61.crate",
|
||||||
"sha256": "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20",
|
"sha256": "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d",
|
||||||
"dest": "cargo/vendor/cc-1.2.60"
|
"dest": "cargo/vendor/cc-1.2.61"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20\", \"files\": {}}",
|
"contents": "{\"package\": \"d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/cc-1.2.60",
|
"dest": "cargo/vendor/cc-1.2.61",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -824,19 +811,6 @@
|
|||||||
"dest": "cargo/vendor/deranged-0.5.8",
|
"dest": "cargo/vendor/deranged-0.5.8",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/derive_arbitrary/derive_arbitrary-1.4.2.crate",
|
|
||||||
"sha256": "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a",
|
|
||||||
"dest": "cargo/vendor/derive_arbitrary-1.4.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/derive_arbitrary-1.4.2",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1113,14 +1087,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/embed-resource/embed-resource-3.0.8.crate",
|
"url": "https://static.crates.io/crates/embed-resource/embed-resource-3.0.9.crate",
|
||||||
"sha256": "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45",
|
"sha256": "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb",
|
||||||
"dest": "cargo/vendor/embed-resource-3.0.8"
|
"dest": "cargo/vendor/embed-resource-3.0.9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45\", \"files\": {}}",
|
"contents": "{\"package\": \"c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/embed-resource-3.0.8",
|
"dest": "cargo/vendor/embed-resource-3.0.9",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1227,19 +1201,6 @@
|
|||||||
"dest": "cargo/vendor/field-offset-0.3.6",
|
"dest": "cargo/vendor/field-offset-0.3.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/filetime/filetime-0.2.27.crate",
|
|
||||||
"sha256": "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db",
|
|
||||||
"dest": "cargo/vendor/filetime-0.2.27"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/filetime-0.2.27",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2205,14 +2166,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.1.crate",
|
"url": "https://static.crates.io/crates/idna_adapter/idna_adapter-1.2.2.crate",
|
||||||
"sha256": "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344",
|
"sha256": "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714",
|
||||||
"dest": "cargo/vendor/idna_adapter-1.2.1"
|
"dest": "cargo/vendor/idna_adapter-1.2.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344\", \"files\": {}}",
|
"contents": "{\"package\": \"cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/idna_adapter-1.2.1",
|
"dest": "cargo/vendor/idna_adapter-1.2.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2504,14 +2465,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/libc/libc-0.2.185.crate",
|
"url": "https://static.crates.io/crates/libc/libc-0.2.186.crate",
|
||||||
"sha256": "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f",
|
"sha256": "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66",
|
||||||
"dest": "cargo/vendor/libc-0.2.185"
|
"dest": "cargo/vendor/libc-0.2.186"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f\", \"files\": {}}",
|
"contents": "{\"package\": \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/libc-0.2.185",
|
"dest": "cargo/vendor/libc-0.2.186",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2722,19 +2683,6 @@
|
|||||||
"dest": "cargo/vendor/mime-0.3.17",
|
"dest": "cargo/vendor/mime-0.3.17",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/minisign-verify/minisign-verify-0.2.5.crate",
|
|
||||||
"sha256": "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e",
|
|
||||||
"dest": "cargo/vendor/minisign-verify-0.2.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/minisign-verify-0.2.5",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3112,19 +3060,6 @@
|
|||||||
"dest": "cargo/vendor/objc2-io-surface-0.3.2",
|
"dest": "cargo/vendor/objc2-io-surface-0.3.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/objc2-osa-kit/objc2-osa-kit-0.3.2.crate",
|
|
||||||
"sha256": "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0",
|
|
||||||
"dest": "cargo/vendor/objc2-osa-kit-0.3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/objc2-osa-kit-0.3.2",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3193,27 +3128,27 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/open/open-5.3.3.crate",
|
"url": "https://static.crates.io/crates/open/open-5.3.4.crate",
|
||||||
"sha256": "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc",
|
"sha256": "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd",
|
||||||
"dest": "cargo/vendor/open-5.3.3"
|
"dest": "cargo/vendor/open-5.3.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc\", \"files\": {}}",
|
"contents": "{\"package\": \"9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/open-5.3.3",
|
"dest": "cargo/vendor/open-5.3.4",
|
||||||
"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/openssl/openssl-0.10.77.crate",
|
"url": "https://static.crates.io/crates/openssl/openssl-0.10.78.crate",
|
||||||
"sha256": "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f",
|
"sha256": "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222",
|
||||||
"dest": "cargo/vendor/openssl-0.10.77"
|
"dest": "cargo/vendor/openssl-0.10.78"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f\", \"files\": {}}",
|
"contents": "{\"package\": \"f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-0.10.77",
|
"dest": "cargo/vendor/openssl-0.10.78",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3245,14 +3180,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.113.crate",
|
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.114.crate",
|
||||||
"sha256": "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644",
|
"sha256": "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.113"
|
"dest": "cargo/vendor/openssl-sys-0.9.114"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644\", \"files\": {}}",
|
"contents": "{\"package\": \"13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/openssl-sys-0.9.113",
|
"dest": "cargo/vendor/openssl-sys-0.9.114",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3294,19 +3229,6 @@
|
|||||||
"dest": "cargo/vendor/os_pipe-1.2.3",
|
"dest": "cargo/vendor/os_pipe-1.2.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/osakit/osakit-0.3.1.crate",
|
|
||||||
"sha256": "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b",
|
|
||||||
"dest": "cargo/vendor/osakit-0.3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/osakit-0.3.1",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3648,27 +3570,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/plain/plain-0.2.3.crate",
|
"url": "https://static.crates.io/crates/plist/plist-1.9.0.crate",
|
||||||
"sha256": "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6",
|
"sha256": "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1",
|
||||||
"dest": "cargo/vendor/plain-0.2.3"
|
"dest": "cargo/vendor/plist-1.9.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6\", \"files\": {}}",
|
"contents": "{\"package\": \"092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/plain-0.2.3",
|
"dest": "cargo/vendor/plist-1.9.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/plist/plist-1.8.0.crate",
|
|
||||||
"sha256": "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07",
|
|
||||||
"dest": "cargo/vendor/plist-1.8.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/plist-1.8.0",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3869,14 +3778,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.38.4.crate",
|
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.2.crate",
|
||||||
"sha256": "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c",
|
"sha256": "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d",
|
||||||
"dest": "cargo/vendor/quick-xml-0.38.4"
|
"dest": "cargo/vendor/quick-xml-0.39.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c\", \"files\": {}}",
|
"contents": "{\"package\": \"958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/quick-xml-0.38.4",
|
"dest": "cargo/vendor/quick-xml-0.39.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3973,14 +3882,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/rand/rand-0.8.5.crate",
|
"url": "https://static.crates.io/crates/rand/rand-0.8.6.crate",
|
||||||
"sha256": "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404",
|
"sha256": "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a",
|
||||||
"dest": "cargo/vendor/rand-0.8.5"
|
"dest": "cargo/vendor/rand-0.8.6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404\", \"files\": {}}",
|
"contents": "{\"package\": \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rand-0.8.5",
|
"dest": "cargo/vendor/rand-0.8.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4152,19 +4061,6 @@
|
|||||||
"dest": "cargo/vendor/redox_syscall-0.5.18",
|
"dest": "cargo/vendor/redox_syscall-0.5.18",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/redox_syscall/redox_syscall-0.7.4.crate",
|
|
||||||
"sha256": "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a",
|
|
||||||
"dest": "cargo/vendor/redox_syscall-0.7.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/redox_syscall-0.7.4",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4272,14 +4168,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.2.crate",
|
"url": "https://static.crates.io/crates/reqwest/reqwest-0.13.3.crate",
|
||||||
"sha256": "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801",
|
"sha256": "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0",
|
||||||
"dest": "cargo/vendor/reqwest-0.13.2"
|
"dest": "cargo/vendor/reqwest-0.13.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801\", \"files\": {}}",
|
"contents": "{\"package\": \"62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/reqwest-0.13.2",
|
"dest": "cargo/vendor/reqwest-0.13.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4350,79 +4246,40 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/rustls/rustls-0.23.38.crate",
|
"url": "https://static.crates.io/crates/rustls/rustls-0.23.39.crate",
|
||||||
"sha256": "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21",
|
"sha256": "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e",
|
||||||
"dest": "cargo/vendor/rustls-0.23.38"
|
"dest": "cargo/vendor/rustls-0.23.39"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21\", \"files\": {}}",
|
"contents": "{\"package\": \"7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rustls-0.23.38",
|
"dest": "cargo/vendor/rustls-0.23.39",
|
||||||
"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/rustls-native-certs/rustls-native-certs-0.8.3.crate",
|
"url": "https://static.crates.io/crates/rustls-pki-types/rustls-pki-types-1.14.1.crate",
|
||||||
"sha256": "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63",
|
"sha256": "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9",
|
||||||
"dest": "cargo/vendor/rustls-native-certs-0.8.3"
|
"dest": "cargo/vendor/rustls-pki-types-1.14.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63\", \"files\": {}}",
|
"contents": "{\"package\": \"30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rustls-native-certs-0.8.3",
|
"dest": "cargo/vendor/rustls-pki-types-1.14.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/rustls-pki-types/rustls-pki-types-1.14.0.crate",
|
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.13.crate",
|
||||||
"sha256": "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd",
|
"sha256": "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e",
|
||||||
"dest": "cargo/vendor/rustls-pki-types-1.14.0"
|
"dest": "cargo/vendor/rustls-webpki-0.103.13"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd\", \"files\": {}}",
|
"contents": "{\"package\": \"61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rustls-pki-types-1.14.0",
|
"dest": "cargo/vendor/rustls-webpki-0.103.13",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/rustls-platform-verifier/rustls-platform-verifier-0.6.2.crate",
|
|
||||||
"sha256": "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784",
|
|
||||||
"dest": "cargo/vendor/rustls-platform-verifier-0.6.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/rustls-platform-verifier-0.6.2",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/rustls-platform-verifier-android/rustls-platform-verifier-android-0.1.1.crate",
|
|
||||||
"sha256": "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f",
|
|
||||||
"dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/rustls-platform-verifier-android-0.1.1",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/rustls-webpki/rustls-webpki-0.103.12.crate",
|
|
||||||
"sha256": "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06",
|
|
||||||
"dest": "cargo/vendor/rustls-webpki-0.103.12"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/rustls-webpki-0.103.12",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5270,19 +5127,6 @@
|
|||||||
"dest": "cargo/vendor/tao-macros-0.1.3",
|
"dest": "cargo/vendor/tao-macros-0.1.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/tar/tar-0.4.45.crate",
|
|
||||||
"sha256": "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973",
|
|
||||||
"dest": "cargo/vendor/tar-0.4.45"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/tar-0.4.45",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -5457,19 +5301,6 @@
|
|||||||
"dest": "cargo/vendor/tauri-plugin-shell-2.3.5",
|
"dest": "cargo/vendor/tauri-plugin-shell-2.3.5",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/tauri-plugin-updater/tauri-plugin-updater-2.10.1.crate",
|
|
||||||
"sha256": "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af",
|
|
||||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/tauri-plugin-updater-2.10.1",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -5512,14 +5343,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tauri-winres/tauri-winres-0.3.5.crate",
|
"url": "https://static.crates.io/crates/tauri-winres/tauri-winres-0.3.6.crate",
|
||||||
"sha256": "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0",
|
"sha256": "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6",
|
||||||
"dest": "cargo/vendor/tauri-winres-0.3.5"
|
"dest": "cargo/vendor/tauri-winres-0.3.6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0\", \"files\": {}}",
|
"contents": "{\"package\": \"cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tauri-winres-0.3.5",
|
"dest": "cargo/vendor/tauri-winres-0.3.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5694,14 +5525,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/tokio/tokio-1.51.1.crate",
|
"url": "https://static.crates.io/crates/tokio/tokio-1.52.1.crate",
|
||||||
"sha256": "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c",
|
"sha256": "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6",
|
||||||
"dest": "cargo/vendor/tokio-1.51.1"
|
"dest": "cargo/vendor/tokio-1.52.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c\", \"files\": {}}",
|
"contents": "{\"package\": \"b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/tokio-1.51.1",
|
"dest": "cargo/vendor/tokio-1.52.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5782,6 +5613,19 @@
|
|||||||
"dest": "cargo/vendor/toml-0.9.12+spec-1.1.0",
|
"dest": "cargo/vendor/toml-0.9.12+spec-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/toml/toml-1.1.2+spec-1.1.0.crate",
|
||||||
|
"sha256": "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee",
|
||||||
|
"dest": "cargo/vendor/toml-1.1.2+spec-1.1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/toml-1.1.2+spec-1.1.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -6006,14 +5850,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/typenum/typenum-1.19.0.crate",
|
"url": "https://static.crates.io/crates/typenum/typenum-1.20.0.crate",
|
||||||
"sha256": "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb",
|
"sha256": "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de",
|
||||||
"dest": "cargo/vendor/typenum-1.19.0"
|
"dest": "cargo/vendor/typenum-1.20.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb\", \"files\": {}}",
|
"contents": "{\"package\": \"40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/typenum-1.19.0",
|
"dest": "cargo/vendor/typenum-1.20.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6214,14 +6058,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/uuid/uuid-1.23.0.crate",
|
"url": "https://static.crates.io/crates/uuid/uuid-1.23.1.crate",
|
||||||
"sha256": "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9",
|
"sha256": "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76",
|
||||||
"dest": "cargo/vendor/uuid-1.23.0"
|
"dest": "cargo/vendor/uuid-1.23.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9\", \"files\": {}}",
|
"contents": "{\"package\": \"ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/uuid-1.23.0",
|
"dest": "cargo/vendor/uuid-1.23.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6344,14 +6188,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.2+wasi-0.2.9.crate",
|
"url": "https://static.crates.io/crates/wasip2/wasip2-1.0.3+wasi-0.2.9.crate",
|
||||||
"sha256": "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5",
|
"sha256": "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6",
|
||||||
"dest": "cargo/vendor/wasip2-1.0.2+wasi-0.2.9"
|
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5\", \"files\": {}}",
|
"contents": "{\"package\": \"20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/wasip2-1.0.2+wasi-0.2.9",
|
"dest": "cargo/vendor/wasip2-1.0.3+wasi-0.2.9",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6513,14 +6357,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/web_atoms/web_atoms-0.2.3.crate",
|
"url": "https://static.crates.io/crates/web_atoms/web_atoms-0.2.4.crate",
|
||||||
"sha256": "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576",
|
"sha256": "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538",
|
||||||
"dest": "cargo/vendor/web_atoms-0.2.3"
|
"dest": "cargo/vendor/web_atoms-0.2.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576\", \"files\": {}}",
|
"contents": "{\"package\": \"d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/web_atoms-0.2.3",
|
"dest": "cargo/vendor/web_atoms-0.2.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -6552,27 +6396,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/webpki-root-certs/webpki-root-certs-1.0.6.crate",
|
"url": "https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.7.crate",
|
||||||
"sha256": "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca",
|
"sha256": "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d",
|
||||||
"dest": "cargo/vendor/webpki-root-certs-1.0.6"
|
"dest": "cargo/vendor/webpki-roots-1.0.7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca\", \"files\": {}}",
|
"contents": "{\"package\": \"52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/webpki-root-certs-1.0.6",
|
"dest": "cargo/vendor/webpki-roots-1.0.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/webpki-roots/webpki-roots-1.0.6.crate",
|
|
||||||
"sha256": "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed",
|
|
||||||
"dest": "cargo/vendor/webpki-roots-1.0.6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/webpki-roots-1.0.6",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7514,14 +7345,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/winnow/winnow-1.0.1.crate",
|
"url": "https://static.crates.io/crates/winnow/winnow-1.0.2.crate",
|
||||||
"sha256": "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5",
|
"sha256": "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0",
|
||||||
"dest": "cargo/vendor/winnow-1.0.1"
|
"dest": "cargo/vendor/winnow-1.0.2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5\", \"files\": {}}",
|
"contents": "{\"package\": \"2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/winnow-1.0.1",
|
"dest": "cargo/vendor/winnow-1.0.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -7550,6 +7381,19 @@
|
|||||||
"dest": "cargo/vendor/wit-bindgen-0.51.0",
|
"dest": "cargo/vendor/wit-bindgen-0.51.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/wit-bindgen/wit-bindgen-0.57.1.crate",
|
||||||
|
"sha256": "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e",
|
||||||
|
"dest": "cargo/vendor/wit-bindgen-0.57.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/wit-bindgen-0.57.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -7667,19 +7511,6 @@
|
|||||||
"dest": "cargo/vendor/x11-dl-2.21.0",
|
"dest": "cargo/vendor/x11-dl-2.21.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/xattr/xattr-1.6.1.crate",
|
|
||||||
"sha256": "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156",
|
|
||||||
"dest": "cargo/vendor/xattr-1.6.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/xattr-1.6.1",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -7810,19 +7641,6 @@
|
|||||||
"dest": "cargo/vendor/zerovec-derive-0.11.3",
|
"dest": "cargo/vendor/zerovec-derive-0.11.3",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "archive",
|
|
||||||
"archive-type": "tar-gzip",
|
|
||||||
"url": "https://static.crates.io/crates/zip/zip-4.6.1.crate",
|
|
||||||
"sha256": "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1",
|
|
||||||
"dest": "cargo/vendor/zip-4.6.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "inline",
|
|
||||||
"contents": "{\"package\": \"caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1\", \"files\": {}}",
|
|
||||||
"dest": "cargo/vendor/zip-4.6.1",
|
|
||||||
"dest-filename": ".cargo-checksum.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=io.github.Youwes09.Moku
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<component type="desktop-application">
|
<component type="desktop-application">
|
||||||
<id>io.github.Youwes09.Moku</id>
|
<id>io.github.moku_project.Moku</id>
|
||||||
<metadata_license>MIT</metadata_license>
|
<metadata_license>MIT</metadata_license>
|
||||||
<project_license>MIT</project_license>
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
@@ -19,30 +19,30 @@
|
|||||||
</p>
|
</p>
|
||||||
</description>
|
</description>
|
||||||
|
|
||||||
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
<caption>Home screen showing your manga library</caption>
|
<caption>Home screen showing your manga library</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
<caption>Built-in manga reader</caption>
|
<caption>Built-in manga reader</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
<caption>Discover new manga across hundreds of sources</caption>
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
<caption>Download manager</caption>
|
<caption>Download manager</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
<caption>Settings</caption>
|
<caption>Settings</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
@@ -54,11 +54,16 @@
|
|||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.8.0" date="2025-04-01">
|
<release version="0.9.0" date="2025-04-01">
|
||||||
<description>
|
<description>
|
||||||
<p>Latest release with improved stability and UI refinements.</p>
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
</description>
|
</description>
|
||||||
</release>
|
</release>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Old release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.4.0" date="2025-03-22">
|
<release version="0.4.0" date="2025-03-22">
|
||||||
<description>
|
<description>
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
@@ -47,15 +47,6 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arbitrary"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
|
||||||
dependencies = [
|
|
||||||
"derive_arbitrary",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -268,9 +259,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.60"
|
version = "1.2.61"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -290,7 +281,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid 1.23.0",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -584,17 +575,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_arbitrary"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -678,7 +658,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -810,14 +790,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.8"
|
version = "3.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
@@ -861,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -889,17 +869,6 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "filetime"
|
|
||||||
version = "0.2.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"libredox",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1732,9 +1701,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna_adapter"
|
name = "idna_adapter"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_normalizer",
|
"icu_normalizer",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
@@ -1969,9 +1938,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.185"
|
version = "0.2.186"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
@@ -1989,10 +1958,7 @@ version = "0.1.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
|
||||||
"redox_syscall 0.7.4",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2103,12 +2069,6 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "minisign-verify"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2132,7 +2092,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.8.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -2147,7 +2107,6 @@ dependencies = [
|
|||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-updater",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -2440,18 +2399,6 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-osa-kit"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
"objc2",
|
|
||||||
"objc2-app-kit",
|
|
||||||
"objc2-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2517,9 +2464,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
@@ -2529,9 +2476,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.77"
|
version = "0.10.78"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
|
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -2561,9 +2508,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.113"
|
version = "0.9.114"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
|
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2600,21 +2547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "osakit"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
|
||||||
dependencies = [
|
|
||||||
"objc2",
|
|
||||||
"objc2-foundation",
|
|
||||||
"objc2-osa-kit",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2660,7 +2593,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall 0.5.18",
|
"redox_syscall",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
@@ -2765,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared 0.10.0",
|
"phf_shared 0.10.0",
|
||||||
"rand 0.8.5",
|
"rand 0.8.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2775,7 +2708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared 0.11.3",
|
"phf_shared 0.11.3",
|
||||||
"rand 0.8.5",
|
"rand 0.8.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2876,17 +2809,11 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "plain"
|
|
||||||
version = "0.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.8.0"
|
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 = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
@@ -3034,9 +2961,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.39.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3133,9 +3060,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.5"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha 0.3.1",
|
||||||
@@ -3262,15 +3189,6 @@ dependencies = [
|
|||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_syscall"
|
|
||||||
version = "0.7.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -3392,9 +3310,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3404,20 +3322,15 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"rustls-platform-verifier",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3492,14 +3405,14 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.38"
|
version = "0.23.39"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -3509,60 +3422,21 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-native-certs"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
|
||||||
dependencies = [
|
|
||||||
"openssl-probe",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"schannel",
|
|
||||||
"security-framework",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-platform-verifier"
|
|
||||||
version = "0.6.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"core-foundation-sys",
|
|
||||||
"jni",
|
|
||||||
"log",
|
|
||||||
"once_cell",
|
|
||||||
"rustls",
|
|
||||||
"rustls-native-certs",
|
|
||||||
"rustls-platform-verifier-android",
|
|
||||||
"rustls-webpki",
|
|
||||||
"security-framework",
|
|
||||||
"security-framework-sys",
|
|
||||||
"webpki-root-certs",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-platform-verifier-android"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.12"
|
version = "0.103.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3611,7 +3485,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.0",
|
"uuid 1.23.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4001,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4019,7 +3893,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"redox_syscall 0.5.18",
|
"redox_syscall",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -4292,17 +4166,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tar"
|
|
||||||
version = "0.4.45"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
|
||||||
dependencies = [
|
|
||||||
"filetime",
|
|
||||||
"libc",
|
|
||||||
"xattr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4339,7 +4202,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4405,7 +4268,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.23.0",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4572,39 +4435,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-plugin-updater"
|
|
||||||
version = "2.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"dirs 6.0.0",
|
|
||||||
"flate2",
|
|
||||||
"futures-util",
|
|
||||||
"http",
|
|
||||||
"infer",
|
|
||||||
"log",
|
|
||||||
"minisign-verify",
|
|
||||||
"osakit",
|
|
||||||
"percent-encoding",
|
|
||||||
"reqwest 0.13.2",
|
|
||||||
"rustls",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tar",
|
|
||||||
"tauri",
|
|
||||||
"tauri-plugin",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"url",
|
|
||||||
"windows-sys 0.60.2",
|
|
||||||
"zip",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -4690,19 +4520,19 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid 1.23.0",
|
"uuid 1.23.1",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-winres"
|
name = "tauri-winres"
|
||||||
version = "0.3.5"
|
version = "0.3.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
|
checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4715,7 +4545,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4837,9 +4667,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.51.1"
|
version = "1.52.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4921,6 +4751,21 @@ dependencies = [
|
|||||||
"winnow 0.7.15",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned 1.1.1",
|
||||||
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -4981,7 +4826,7 @@ 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",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.1",
|
"winnow 1.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4990,7 +4835,7 @@ version = "1.1.2+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 = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.1",
|
"winnow 1.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5099,9 +4944,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
@@ -5222,9 +5067,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -5303,11 +5148,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.2+wasi-0.2.9"
|
version = "1.0.3+wasi-0.2.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen",
|
"wit-bindgen 0.57.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5316,7 +5161,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen",
|
"wit-bindgen 0.51.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5443,9 +5288,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
|
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen 0.13.1",
|
||||||
@@ -5497,20 +5342,11 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-root-certs"
|
|
||||||
version = "1.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.6"
|
version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -5573,7 +5409,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6132,9 +5968,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -6158,6 +5994,12 @@ dependencies = [
|
|||||||
"wit-bindgen-rust-macro",
|
"wit-bindgen-rust-macro",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen-core"
|
name = "wit-bindgen-core"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -6308,16 +6150,6 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xattr"
|
|
||||||
version = "1.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rustix",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -6421,18 +6253,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zip"
|
|
||||||
version = "4.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
|
||||||
dependencies = [
|
|
||||||
"arbitrary",
|
|
||||||
"crc32fast",
|
|
||||||
"indexmap 2.14.0",
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.8.0"
|
version = "0.9.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -17,7 +17,6 @@ tauri-build = { version = "2.0", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Moku — Suwayomi launcher for Linux AppImage/deb.
|
||||||
|
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||||
|
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||||
|
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||||
|
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||||
|
# We resolve relative to this script's own location.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# Tauri places resources one level up from the binary on Linux.
|
||||||
|
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||||
|
find_resource() {
|
||||||
|
for candidate in \
|
||||||
|
"${SCRIPT_DIR}" \
|
||||||
|
"${SCRIPT_DIR}/../resources" \
|
||||||
|
"${SCRIPT_DIR}/resources"
|
||||||
|
do
|
||||||
|
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DIR=$(find_resource) || {
|
||||||
|
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||||
|
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||||
|
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||||
|
|
||||||
|
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||||
|
echo "[launcher] JAVA=$JAVA" >&2
|
||||||
|
echo "[launcher] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||||
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
|
server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = false
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "stable"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
|
sed -i \
|
||||||
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||||
|
unset DISPLAY
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
|
||||||
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
|
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||||
|
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||||
|
# bring down the whole server process (mirrors the Flatpak build).
|
||||||
|
if [ -f "$CATCH_ABORT" ]; then
|
||||||
|
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dapple.awt.UIElement=true \
|
||||||
|
-Dsun.java2d.noddraw=true \
|
||||||
|
-Dsun.awt.disablegui=true \
|
||||||
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
"core:window:allow-inner-position",
|
"core:window:allow-inner-position",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor",
|
"core:window:allow-scale-factor",
|
||||||
"updater:default",
|
|
||||||
"updater:allow-check",
|
|
||||||
"updater:allow-download-and-install",
|
|
||||||
"process:default",
|
"process:default",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ fn suwayomi_data_dir() -> PathBuf {
|
|||||||
{
|
{
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||||
.join("io.github.Youwes09.Moku.app/tachidesk")
|
.join("io.github.moku_project.Moku.app/tachidesk")
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
@@ -405,17 +405,14 @@ fn resolve_server_binary(
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
// Root of Moku.app/Contents/ — scan every subdirectory level by level.
|
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let contents_dir = resource_dir
|
let contents_dir = resource_dir
|
||||||
.parent() // Moku.app/Contents/
|
.parent()
|
||||||
.unwrap_or(&resource_dir)
|
.unwrap_or(&resource_dir)
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
||||||
|
|
||||||
// Native-binary names we recognise (most specific first so arch-specific
|
|
||||||
// names win over the generic "suwayomi-server" if both somehow exist).
|
|
||||||
const NATIVE_NAMES: &[&str] = &[
|
const NATIVE_NAMES: &[&str] = &[
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
@@ -425,11 +422,8 @@ fn resolve_server_binary(
|
|||||||
"tachidesk-server",
|
"tachidesk-server",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Collect every directory inside Contents/, grouped by depth so we
|
|
||||||
// search shallower levels first (BFS order via WalkDir min/max_depth).
|
|
||||||
// We go up to depth 8 which is more than enough for any real bundle.
|
|
||||||
let mut found_binary: Option<ServerInvocation> = None;
|
let mut found_binary: Option<ServerInvocation> = None;
|
||||||
let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar)
|
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||||
|
|
||||||
'outer: for depth in 0u8..=8 {
|
'outer: for depth in 0u8..=8 {
|
||||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||||
@@ -444,7 +438,6 @@ fn resolve_server_binary(
|
|||||||
for dir in &entries {
|
for dir in &entries {
|
||||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
||||||
|
|
||||||
// 1. Look for a native server binary in this directory.
|
|
||||||
for name in NATIVE_NAMES {
|
for name in NATIVE_NAMES {
|
||||||
let p = dir.join(name);
|
let p = dir.join(name);
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
@@ -458,15 +451,10 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Look for a JRE java binary paired with a .jar in the same
|
|
||||||
// or sibling directories. We record the first hit and keep
|
|
||||||
// scanning natives; if no native is ever found we fall back
|
|
||||||
// to this.
|
|
||||||
if found_java.is_none() {
|
if found_java.is_none() {
|
||||||
let java_exe = dir.join("bin").join("java");
|
let java_exe = dir.join("bin").join("java");
|
||||||
if java_exe.exists() {
|
if java_exe.exists() {
|
||||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||||
// Search upward from the JRE dir for a .jar file.
|
|
||||||
let mut search = dir.as_path();
|
let mut search = dir.as_path();
|
||||||
'jar: for _ in 0..5 {
|
'jar: for _ in 0..5 {
|
||||||
if let Ok(rd) = std::fs::read_dir(search) {
|
if let Ok(rd) = std::fs::read_dir(search) {
|
||||||
@@ -479,7 +467,6 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also look in a sibling `bin/` directory.
|
|
||||||
let bin_sibling = search.join("bin");
|
let bin_sibling = search.join("bin");
|
||||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||||
for entry in rd.filter_map(|e| e.ok()) {
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
@@ -602,7 +589,7 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -634,32 +621,62 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_http::reqwest;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
let client = reqwest::Client::builder()
|
||||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
.user_agent("Moku")
|
||||||
|
.build()
|
||||||
let Some(update) = update else {
|
|
||||||
return Err("No update available.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
move |downloaded, total| {
|
|
||||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
|
||||||
|
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Asset { name: String, browser_download_url: String, size: u64 }
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Release { assets: Vec<Asset> }
|
||||||
|
|
||||||
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
|
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let asset = release.assets
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
|
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||||
|
|
||||||
|
let total = if asset.size > 0 { Some(asset.size) } else { None };
|
||||||
|
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||||
|
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
std::process::Command::new(&tmp_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = app.emit("update-launching", ());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
@@ -709,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
.map(|p| p.to_string())
|
.map(|p| p.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path().app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let filename = format!("moku-backup-{}.json", now);
|
||||||
|
|
||||||
|
let path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
let dest = PathBuf::from(path.to_string());
|
||||||
|
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(dest.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
let src = PathBuf::from(path.to_string());
|
||||||
|
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
||||||
|
let backup_dir = moku_backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
|
||||||
|
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
moku_backup_dir(&app).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -718,7 +811,6 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
get_storage_info,
|
||||||
@@ -734,6 +826,10 @@ pub fn run() {
|
|||||||
restart_app,
|
restart_app,
|
||||||
open_path,
|
open_path,
|
||||||
pick_downloads_folder,
|
pick_downloads_folder,
|
||||||
|
export_app_data,
|
||||||
|
import_app_data,
|
||||||
|
auto_backup_app_data,
|
||||||
|
get_auto_backup_dir,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.8.0",
|
"version": "0.9.1",
|
||||||
"identifier": "io.github.Youwes09.Moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
@@ -27,9 +27,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [
|
"targets": ["nsis"],
|
||||||
"nsis"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -49,10 +47,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage", "deb"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-launcher-linux"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||||
|
"binaries/suwayomi-bundle/jre": "jre"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
{
|
{
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": true,
|
|
||||||
"resources": [
|
"resources": [
|
||||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||||
"binaries/suwayomi-bundle/jre/**/*"
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
|
||||||
],
|
|
||||||
"windows": {
|
|
||||||
"installMode": "passive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -12,6 +12,12 @@ function getServerBase(): string {
|
|||||||
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 timeoutSignal(ms: number): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), ms);
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||||
}
|
}
|
||||||
@@ -34,7 +40,7 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
|
|||||||
method: "POST", credentials: "omit",
|
method: "POST", credentials: "omit",
|
||||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: timeoutSignal(5000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||||
@@ -58,7 +64,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
|||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST", credentials: "omit", headers,
|
method: "POST", credentials: "omit", headers,
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: timeoutSignal(5000),
|
||||||
});
|
});
|
||||||
if (res.ok) return "ok";
|
if (res.ok) return "ok";
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
function collectAppData(): Record<string, string> {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAppData(data: Record<string, string>): void {
|
||||||
|
localStorage.clear();
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAppData(): Promise<void> {
|
||||||
|
const json = JSON.stringify(collectAppData(), null, 2);
|
||||||
|
await invoke("export_app_data", { json });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAppData(): Promise<void> {
|
||||||
|
const json = await invoke<string>("import_app_data");
|
||||||
|
const data: Record<string, string> = JSON.parse(json);
|
||||||
|
applyAppData(data);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(collectAppData());
|
||||||
|
await invoke("auto_backup_app_data", { json });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[moku] auto-backup failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
let themeStyleEl: HTMLStyleElement | null = null;
|
||||||
|
let mediaQuery: MediaQueryList | null = null;
|
||||||
|
let mediaHandler: (() => void) | null = null;
|
||||||
|
|
||||||
export function applyTheme() {
|
export function applyTheme() {
|
||||||
const themeId = store.settings.theme ?? "dark";
|
const themeId = store.settings.theme ?? "dark";
|
||||||
@@ -34,3 +36,32 @@ export function applyTheme() {
|
|||||||
themeStyleEl.textContent = css;
|
themeStyleEl.textContent = css;
|
||||||
document.documentElement.setAttribute("data-theme", "custom");
|
document.documentElement.setAttribute("data-theme", "custom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySystemTheme(dark: boolean) {
|
||||||
|
const themeId = dark
|
||||||
|
? (store.settings.systemThemeDark ?? "dark")
|
||||||
|
: (store.settings.systemThemeLight ?? "light");
|
||||||
|
updateSettings({ theme: themeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountSystemThemeSync() {
|
||||||
|
if (mediaQuery && mediaHandler) {
|
||||||
|
mediaQuery.removeEventListener("change", mediaHandler);
|
||||||
|
mediaHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.settings.systemThemeSync) return;
|
||||||
|
|
||||||
|
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
||||||
|
mediaQuery.addEventListener("change", mediaHandler);
|
||||||
|
applySystemTheme(mediaQuery.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmountSystemThemeSync() {
|
||||||
|
if (mediaQuery && mediaHandler) {
|
||||||
|
mediaQuery.removeEventListener("change", mediaHandler);
|
||||||
|
mediaHandler = null;
|
||||||
|
mediaQuery = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
[data-theme="high-contrast"] {
|
[data-theme="dark"] {
|
||||||
--bg-void: #000000;
|
--bg-void: #000000;
|
||||||
--bg-base: #080808;
|
--bg-base: #080808;
|
||||||
--bg-surface: #0d0d0d;
|
--bg-surface: #0d0d0d;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
@import "./high-contrast.css";
|
@import "./original.css";
|
||||||
@import "./light-contrast.css";
|
@import "./dark.css";
|
||||||
@import "./light.css";
|
@import "./light.css";
|
||||||
@import "./midnight.css";
|
@import "./midnight.css";
|
||||||
@import "./warm.css";
|
@import "./warm.css";
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
[data-theme="light-contrast"] {
|
|
||||||
--bg-void: #d8d4ce;
|
|
||||||
--bg-base: #e2deda;
|
|
||||||
--bg-surface: #ece8e2;
|
|
||||||
--bg-raised: #f5f2ec;
|
|
||||||
--bg-overlay: #ffffff;
|
|
||||||
--bg-subtle: #e4e0d8;
|
|
||||||
|
|
||||||
--border-dim: #c4c0b8;
|
|
||||||
--border-base: #b0aca4;
|
|
||||||
--border-strong: #989490;
|
|
||||||
--border-focus: #3a5a3a;
|
|
||||||
|
|
||||||
--text-primary: #080806;
|
|
||||||
--text-secondary: #181612;
|
|
||||||
--text-muted: #38342e;
|
|
||||||
--text-faint: #706c64;
|
|
||||||
--text-disabled: #b0aca4;
|
|
||||||
|
|
||||||
--accent: #2a5a2a;
|
|
||||||
--accent-dim: #b0ccb0;
|
|
||||||
--accent-muted: #c8dcc8;
|
|
||||||
--accent-fg: #183818;
|
|
||||||
--accent-bright: #1e4e1e;
|
|
||||||
|
|
||||||
--color-error: #8a1a1a;
|
|
||||||
--color-error-bg: #f8e0e0;
|
|
||||||
--color-read: #e0dcd4;
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,29 @@
|
|||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-void: #e8e6e2;
|
--bg-void: #d8d4ce;
|
||||||
--bg-base: #eeece8;
|
--bg-base: #e2deda;
|
||||||
--bg-surface: #f4f2ee;
|
--bg-surface: #ece8e2;
|
||||||
--bg-raised: #faf8f4;
|
--bg-raised: #f5f2ec;
|
||||||
--bg-overlay: #ffffff;
|
--bg-overlay: #ffffff;
|
||||||
--bg-subtle: #f0ede8;
|
--bg-subtle: #e4e0d8;
|
||||||
|
|
||||||
--border-dim: #dedad4;
|
--border-dim: #c4c0b8;
|
||||||
--border-base: #d0ccc6;
|
--border-base: #b0aca4;
|
||||||
--border-strong: #bbb6ae;
|
--border-strong: #989490;
|
||||||
--border-focus: #5a7a5a;
|
--border-focus: #3a5a3a;
|
||||||
|
|
||||||
--text-primary: #1a1916;
|
--text-primary: #080806;
|
||||||
--text-secondary: #2e2c28;
|
--text-secondary: #181612;
|
||||||
--text-muted: #5a5750;
|
--text-muted: #38342e;
|
||||||
--text-faint: #9a9890;
|
--text-faint: #706c64;
|
||||||
--text-disabled: #c8c4bc;
|
--text-disabled: #b0aca4;
|
||||||
|
|
||||||
--accent: #4a724a;
|
--accent: #2a5a2a;
|
||||||
--accent-dim: #c8dcc8;
|
--accent-dim: #b0ccb0;
|
||||||
--accent-muted: #deeade;
|
--accent-muted: #c8dcc8;
|
||||||
--accent-fg: #2a5a2a;
|
--accent-fg: #183818;
|
||||||
--accent-bright: #3a6a3a;
|
--accent-bright: #1e4e1e;
|
||||||
|
|
||||||
--color-error: #a03030;
|
--color-error: #8a1a1a;
|
||||||
--color-error-bg: #fce8e8;
|
--color-error-bg: #f8e0e0;
|
||||||
--color-success: #2a6a2a;
|
--color-read: #e0dcd4;
|
||||||
--color-info: #2a4a7a;
|
|
||||||
--color-info-bg: #e8eef8;
|
|
||||||
--color-read: #e8e4dc;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[data-theme="original"] {
|
||||||
|
--bg-void: #080808;
|
||||||
|
--bg-base: #0c0c0c;
|
||||||
|
--bg-surface: #101010;
|
||||||
|
--bg-raised: #151515;
|
||||||
|
--bg-overlay: #1a1a1a;
|
||||||
|
--bg-subtle: #202020;
|
||||||
|
|
||||||
|
--border-dim: #1c1c1c;
|
||||||
|
--border-base: #242424;
|
||||||
|
--border-strong: #2e2e2e;
|
||||||
|
--border-focus: #4a5c4a;
|
||||||
|
|
||||||
|
--text-primary: #f0efec;
|
||||||
|
--text-secondary: #c8c6c0;
|
||||||
|
--text-muted: #8a8880;
|
||||||
|
--text-faint: #4e4d4a;
|
||||||
|
--text-disabled: #2a2a28;
|
||||||
|
|
||||||
|
--accent: #6b8f6b;
|
||||||
|
--accent-dim: #2a3d2a;
|
||||||
|
--accent-muted: #1a251a;
|
||||||
|
--accent-fg: #a8c4a8;
|
||||||
|
--accent-bright: #8fb88f;
|
||||||
|
|
||||||
|
--color-error: #c47a7a;
|
||||||
|
--color-error-bg: #1f1212;
|
||||||
|
--color-success: #7aab7a;
|
||||||
|
--color-info: #7a9ec4;
|
||||||
|
--color-info-bg: #121a1f;
|
||||||
|
}
|
||||||
@@ -32,4 +32,6 @@
|
|||||||
|
|
||||||
--dot-active: var(--accent);
|
--dot-active: var(--accent);
|
||||||
--dot-inactive: var(--text-faint);
|
--dot-inactive: var(--text-faint);
|
||||||
|
|
||||||
|
--bg-image: none;
|
||||||
}
|
}
|
||||||
@@ -30,17 +30,10 @@
|
|||||||
if (!tabsEl) return;
|
if (!tabsEl) return;
|
||||||
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const containerLeft = tabsEl.getBoundingClientRect().left;
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
tabIndicator = {
|
|
||||||
left: active.getBoundingClientRect().left - containerLeft,
|
|
||||||
width: active.offsetWidth,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
tab; // reactive on tab change
|
|
||||||
if (anims) requestAnimationFrame(updateIndicator);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SEARCH_PAGES = 3;
|
const SEARCH_PAGES = 3;
|
||||||
const SEARCH_LIMIT = 200;
|
const SEARCH_LIMIT = 200;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||||
|
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||||
import type { Manga, Source } from "@types";
|
import type { Manga, Source } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -28,6 +30,17 @@
|
|||||||
let src_currentPage = $state(1);
|
let src_currentPage = $state(1);
|
||||||
let src_abortCtrl: AbortController | null = null;
|
let src_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
|
let ctx_x = $state(0);
|
||||||
|
let ctx_y = $state(0);
|
||||||
|
let ctx_source: Source | null = $state(null);
|
||||||
|
|
||||||
|
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
|
||||||
|
const pinnedSources = $derived(
|
||||||
|
pinnedIds
|
||||||
|
.map(id => allSources.find(s => s.id === id))
|
||||||
|
.filter((s): s is Source => !!s)
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!allSources.length) return;
|
if (!allSources.length) return;
|
||||||
const langs = new Set(allSources.map((s) => s.lang));
|
const langs = new Set(allSources.map((s) => s.lang));
|
||||||
@@ -93,11 +106,16 @@
|
|||||||
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCtx(e: MouseEvent, src: Source) {
|
||||||
|
e.preventDefault();
|
||||||
|
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
|
||||||
|
}
|
||||||
|
function closeCtx() { ctx_source = null; }
|
||||||
|
|
||||||
onDestroy(() => { src_abortCtrl?.abort(); });
|
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="splitRoot">
|
<div class="splitRoot">
|
||||||
|
|
||||||
<div class="splitSidebar">
|
<div class="splitSidebar">
|
||||||
<div class="srcLangRow">
|
<div class="srcLangRow">
|
||||||
<span class="langPocketLabel">Language</span>
|
<span class="langPocketLabel">Language</span>
|
||||||
@@ -122,6 +140,7 @@
|
|||||||
class="splitItem splitItemSource"
|
class="splitItem splitItemSource"
|
||||||
class:splitItemActive={src_activeSource?.id === localSource.id}
|
class:splitItemActive={src_activeSource?.id === localSource.id}
|
||||||
onclick={() => srcSelectSource(localSource)}
|
onclick={() => srcSelectSource(localSource)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, localSource)}
|
||||||
>
|
>
|
||||||
<div class="localSourceIcon">
|
<div class="localSourceIcon">
|
||||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
@@ -132,11 +151,34 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="localDivider"></div>
|
<div class="localDivider"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if pinnedSources.length > 0}
|
||||||
|
<p class="sectionLabel">Pinned</p>
|
||||||
|
{#each pinnedSources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
|
<span class="pinIndicator" title="Pinned">
|
||||||
|
<PushPin size={9} weight="fill" />
|
||||||
|
</span>
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="localDivider"></div>
|
||||||
|
<p class="sectionLabel">All Sources</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#each src_visibleSources as src (src.id)}
|
{#each src_visibleSources as src (src.id)}
|
||||||
<button
|
<button
|
||||||
class="splitItem splitItemSource"
|
class="splitItem splitItemSource"
|
||||||
class:splitItemActive={src_activeSource?.id === src.id}
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
onclick={() => srcSelectSource(src)}
|
onclick={() => srcSelectSource(src)}
|
||||||
|
oncontextmenu={(e) => openCtx(e, src)}
|
||||||
>
|
>
|
||||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span class="splitItemLabel">{src.name}</span>
|
<span class="splitItemLabel">{src.name}</span>
|
||||||
@@ -235,6 +277,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if ctx_source}
|
||||||
|
{@const isPinned = pinnedIds.includes(ctx_source.id)}
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx_x}
|
||||||
|
y={ctx_y}
|
||||||
|
onClose={closeCtx}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: isPinned ? "Unpin source" : "Pin source",
|
||||||
|
icon: isPinned ? PushPinSlash : PushPin,
|
||||||
|
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Browse source",
|
||||||
|
icon: ArrowRight,
|
||||||
|
onClick: () => { srcSelectSource(ctx_source!); },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||||
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
@@ -256,6 +320,8 @@
|
|||||||
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||||
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||||
|
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
|
||||||
|
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
|
||||||
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
||||||
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|||||||
@@ -1,37 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, ArrowUp, ArrowDown, ArrowClockwise, X } from "phosphor-svelte";
|
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
|
||||||
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
|
||||||
import type { DownloadQueueItem } from "@types/index";
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
import { pageProgress } from "../lib/downloadQueue";
|
import { pageProgress } from "../lib/downloadQueue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: DownloadQueueItem;
|
item: DownloadQueueItem;
|
||||||
index: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
isRemoving: boolean;
|
isRemoving: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
selectedCount: number;
|
|
||||||
selectedErrorCount: number;
|
|
||||||
batchWorking: boolean;
|
|
||||||
onRemove: (chapterId: number) => void;
|
onRemove: (chapterId: number) => void;
|
||||||
onRetry: (chapterId: number) => void;
|
onRetry: (chapterId: number) => void;
|
||||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||||
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
|
||||||
onBatchRemove: () => void;
|
|
||||||
onBatchRetry: () => void;
|
|
||||||
onBatchReorder: (dir: "up" | "down") => void;
|
|
||||||
onClearSelect: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
item, index, isActive, isFirst, isLast, isRemoving,
|
item, isActive, isRemoving, isSelected,
|
||||||
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
onRemove, onRetry, onSelect,
|
||||||
onRemove, onRetry, onReorder, onSelect,
|
|
||||||
onBatchRemove, onBatchRetry, onBatchReorder, onClearSelect,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const manga = $derived(item.chapter.manga);
|
const manga = $derived(item.chapter.manga);
|
||||||
@@ -39,118 +24,6 @@
|
|||||||
const prog = $derived(pageProgress(item.progress, pages));
|
const prog = $derived(pageProgress(item.progress, pages));
|
||||||
const isError = $derived(item.state === "ERROR");
|
const isError = $derived(item.state === "ERROR");
|
||||||
const pct = $derived(Math.round(item.progress * 100));
|
const pct = $derived(Math.round(item.progress * 100));
|
||||||
|
|
||||||
let menuX = $state(0);
|
|
||||||
let menuY = $state(0);
|
|
||||||
let menuOpen = $state(false);
|
|
||||||
|
|
||||||
function openMenu(e: MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
menuX = e.clientX;
|
|
||||||
menuY = e.clientY;
|
|
||||||
menuOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let touchMoved = false;
|
|
||||||
|
|
||||||
function onTouchStart(e: TouchEvent) {
|
|
||||||
touchMoved = false;
|
|
||||||
const touch = e.touches[0];
|
|
||||||
longPressTimer = setTimeout(() => {
|
|
||||||
longPressTimer = null;
|
|
||||||
if (touchMoved) return;
|
|
||||||
if (selectedCount === 0) {
|
|
||||||
onSelect(item.chapter.id, { shiftKey: false, ctrlKey: false, metaKey: false });
|
|
||||||
} else {
|
|
||||||
menuX = touch.clientX;
|
|
||||||
menuY = touch.clientY;
|
|
||||||
menuOpen = true;
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchMove() {
|
|
||||||
touchMoved = true;
|
|
||||||
cancelLongPress();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelLongPress() {
|
|
||||||
if (longPressTimer !== null) {
|
|
||||||
clearTimeout(longPressTimer);
|
|
||||||
longPressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems = $derived.by<MenuEntry[]>(() => {
|
|
||||||
const inBatch = isSelected && selectedCount > 1;
|
|
||||||
const entries: MenuEntry[] = [];
|
|
||||||
|
|
||||||
if (inBatch) {
|
|
||||||
entries.push({
|
|
||||||
label: `Move up (${selectedCount})`,
|
|
||||||
icon: ArrowUp,
|
|
||||||
onClick: () => onBatchReorder("up"),
|
|
||||||
disabled: batchWorking,
|
|
||||||
});
|
|
||||||
entries.push({
|
|
||||||
label: `Move down (${selectedCount})`,
|
|
||||||
icon: ArrowDown,
|
|
||||||
onClick: () => onBatchReorder("down"),
|
|
||||||
disabled: batchWorking,
|
|
||||||
});
|
|
||||||
entries.push({ separator: true });
|
|
||||||
if (selectedErrorCount > 0) {
|
|
||||||
entries.push({
|
|
||||||
label: `Retry errors (${selectedErrorCount})`,
|
|
||||||
icon: ArrowClockwise,
|
|
||||||
onClick: onBatchRetry,
|
|
||||||
disabled: batchWorking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
entries.push({
|
|
||||||
label: `Remove selected (${selectedCount})`,
|
|
||||||
icon: X,
|
|
||||||
onClick: onBatchRemove,
|
|
||||||
danger: true,
|
|
||||||
disabled: batchWorking,
|
|
||||||
});
|
|
||||||
entries.push({ separator: true });
|
|
||||||
entries.push({ label: "Deselect all", onClick: onClearSelect });
|
|
||||||
} else {
|
|
||||||
if (isError) {
|
|
||||||
entries.push({
|
|
||||||
label: "Retry",
|
|
||||||
icon: ArrowClockwise,
|
|
||||||
onClick: () => onRetry(item.chapter.id),
|
|
||||||
disabled: isRemoving,
|
|
||||||
});
|
|
||||||
entries.push({ separator: true });
|
|
||||||
}
|
|
||||||
entries.push({
|
|
||||||
label: "Move up",
|
|
||||||
icon: ArrowUp,
|
|
||||||
onClick: () => onReorder(item.chapter.id, "up"),
|
|
||||||
disabled: isFirst || isActive,
|
|
||||||
});
|
|
||||||
entries.push({
|
|
||||||
label: "Move down",
|
|
||||||
icon: ArrowDown,
|
|
||||||
onClick: () => onReorder(item.chapter.id, "down"),
|
|
||||||
disabled: isLast || isActive,
|
|
||||||
});
|
|
||||||
entries.push({ separator: true });
|
|
||||||
entries.push({
|
|
||||||
label: "Remove",
|
|
||||||
icon: X,
|
|
||||||
onClick: () => onRemove(item.chapter.id),
|
|
||||||
danger: true,
|
|
||||||
disabled: isRemoving || isActive,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -160,10 +33,6 @@
|
|||||||
class:row-selected={isSelected}
|
class:row-selected={isSelected}
|
||||||
class:row-removing={isRemoving}
|
class:row-removing={isRemoving}
|
||||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||||
oncontextmenu={openMenu}
|
|
||||||
ontouchstart={onTouchStart}
|
|
||||||
ontouchend={cancelLongPress}
|
|
||||||
ontouchmove={onTouchMove}
|
|
||||||
>
|
>
|
||||||
{#if manga?.thumbnailUrl}
|
{#if manga?.thumbnailUrl}
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
@@ -201,12 +70,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isActive}
|
{#if !isActive}
|
||||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
|
|
||||||
<ArrowUp size={11} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
|
|
||||||
<ArrowDown size={11} weight="light" />
|
|
||||||
</button>
|
|
||||||
<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}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -215,10 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if menuOpen}
|
|
||||||
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -235,118 +94,33 @@
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
.row.row-active { border-color: var(--accent-dim); }
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
.thumb {
|
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
.info {
|
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manga-title {
|
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
font-size: var(--text-sm);
|
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-name {
|
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
font-size: var(--text-xs);
|
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
color: var(--text-muted);
|
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-wrap {
|
|
||||||
flex: 1;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--border-base);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.row-active .progress-bar { opacity: 1; }
|
.row-active .progress-bar { opacity: 1; }
|
||||||
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
||||||
|
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||||
|
|
||||||
.pages-label {
|
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
font-family: var(--font-ui);
|
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-label {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||||
|
|
||||||
.actions {
|
.actions { display: flex; align-items: center; gap: 2px; }
|
||||||
display: flex;
|
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||||
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|||||||
@@ -9,26 +9,17 @@
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
dequeueing: Set<number>;
|
dequeueing: Set<number>;
|
||||||
selected: Set<number>;
|
selected: Set<number>;
|
||||||
batchWorking: boolean;
|
|
||||||
onRemove: (chapterId: number) => void;
|
onRemove: (chapterId: number) => void;
|
||||||
onRetry: (chapterId: number) => void;
|
onRetry: (chapterId: number) => void;
|
||||||
onReorder: (chapterId: number, dir: "up" | "down") => 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;
|
||||||
onClearSelect: () => void;
|
|
||||||
onBatchRemove: () => void;
|
|
||||||
onBatchRetry: () => void;
|
|
||||||
onBatchReorder: (dir: "up" | "down") => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
queue, loading, isRunning, dequeueing, selected, batchWorking,
|
queue, loading, isRunning, dequeueing, selected,
|
||||||
onRemove, onRetry, onReorder, onSelect, onClearSelect,
|
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||||
onBatchRemove, onBatchRetry, onBatchReorder,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const selectedErrorCount = $derived(
|
|
||||||
queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -42,43 +33,20 @@
|
|||||||
{#each queue as item, i (item.chapter.id)}
|
{#each queue as item, i (item.chapter.id)}
|
||||||
<DownloadItem
|
<DownloadItem
|
||||||
{item}
|
{item}
|
||||||
index={i}
|
|
||||||
isActive={i === 0 && isRunning}
|
isActive={i === 0 && isRunning}
|
||||||
isFirst={i === 0}
|
|
||||||
isLast={i === queue.length - 1}
|
|
||||||
isRemoving={dequeueing.has(item.chapter.id)}
|
isRemoving={dequeueing.has(item.chapter.id)}
|
||||||
isSelected={selected.has(item.chapter.id)}
|
isSelected={selected.has(item.chapter.id)}
|
||||||
selectedCount={selected.size}
|
|
||||||
{selectedErrorCount}
|
|
||||||
{batchWorking}
|
|
||||||
{onRemove}
|
{onRemove}
|
||||||
{onRetry}
|
{onRetry}
|
||||||
{onReorder}
|
{onReorder}
|
||||||
|
{onReorderEdge}
|
||||||
{onSelect}
|
{onSelect}
|
||||||
{onClearSelect}
|
|
||||||
{onBatchRemove}
|
|
||||||
{onBatchRetry}
|
|
||||||
{onBatchReorder}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.list {
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
display: flex;
|
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 160px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash } from "phosphor-svelte";
|
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
|
||||||
|
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
|
||||||
import DownloadQueue from "./DownloadQueue.svelte";
|
import DownloadQueue from "./DownloadQueue.svelte";
|
||||||
import { downloadStore } from "../store/downloadState.svelte";
|
import { downloadStore } from "../store/downloadState.svelte";
|
||||||
import { formatEta } from "../lib/downloadQueue";
|
import { formatEta } from "../lib/downloadQueue";
|
||||||
@@ -10,6 +11,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let selectAnchor = $state<number | null>(null);
|
let selectAnchor = $state<number | null>(null);
|
||||||
|
let moveBy = $state(1);
|
||||||
|
|
||||||
|
const selectedErrorCount = $derived(
|
||||||
|
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||||
|
);
|
||||||
|
|
||||||
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
||||||
const ctrl = e.ctrlKey || e.metaKey;
|
const ctrl = e.ctrlKey || e.metaKey;
|
||||||
@@ -39,12 +45,25 @@
|
|||||||
selectAnchor = null;
|
selectAnchor = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
downloadStore.clearSelection();
|
||||||
|
selectAnchor = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="heading">Downloads</h1>
|
<h1 class="heading">Downloads</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={downloadStore.autoRetryEnabled}
|
||||||
|
onclick={() => downloadStore.toggleAutoRetry()}
|
||||||
|
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
|
||||||
|
>
|
||||||
|
<Repeat size={14} weight="regular" />
|
||||||
|
</button>
|
||||||
{#if downloadStore.hasErrored}
|
{#if downloadStore.hasErrored}
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -95,145 +114,119 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content" onclick={handleClickOff}>
|
<div class="bar-wrap">
|
||||||
<div class="status-bar">
|
<div class="status-bar" onclick={handleClickOff} role="presentation">
|
||||||
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||||
<span class="status-text">
|
<span class="status-text">
|
||||||
{downloadStore.togglingPlay
|
{downloadStore.togglingPlay
|
||||||
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
||||||
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
||||||
</span>
|
</span>
|
||||||
|
{#if downloadStore.selected.size > 0}
|
||||||
|
<div class="sel-controls">
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
||||||
|
<ArrowLineUp size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<div class="move-step" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
||||||
|
<CaretUp size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
class="move-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={moveBy}
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
|
||||||
|
<CaretDown size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
|
||||||
|
<ArrowLineDown size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{#if selectedErrorCount > 0}
|
||||||
|
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
|
||||||
|
<ArrowClockwise size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
<span class="status-count">{downloadStore.selected.size} selected</span>
|
||||||
|
{:else}
|
||||||
<div class="status-right">
|
<div class="status-right">
|
||||||
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||||
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="status-count">{downloadStore.queue.length} queued</span>
|
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content" onclick={handleClickOff}>
|
||||||
<DownloadQueue
|
<DownloadQueue
|
||||||
queue={downloadStore.queue}
|
queue={downloadStore.queue}
|
||||||
loading={downloadStore.loading}
|
loading={downloadStore.loading}
|
||||||
isRunning={downloadStore.isRunning}
|
isRunning={downloadStore.isRunning}
|
||||||
dequeueing={downloadStore.dequeueing}
|
dequeueing={downloadStore.dequeueing}
|
||||||
selected={downloadStore.selected}
|
selected={downloadStore.selected}
|
||||||
batchWorking={downloadStore.batchWorking}
|
|
||||||
onRemove={(id) => downloadStore.dequeue(id)}
|
onRemove={(id) => downloadStore.dequeue(id)}
|
||||||
onRetry={(id) => downloadStore.retryOne(id)}
|
onRetry={(id) => downloadStore.retryOne(id)}
|
||||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||||
|
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
|
|
||||||
onBatchRemove={() => downloadStore.dequeueSelected()}
|
|
||||||
onBatchRetry={() => downloadStore.retrySelected()}
|
|
||||||
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root {
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.header-actions { display: flex; gap: var(--sp-2); }
|
.header-actions { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
.content {
|
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||||
display: flex;
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||||
align-items: center;
|
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||||
justify-content: center;
|
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||||
width: 28px;
|
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
height: 28px;
|
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
|
||||||
border-radius: var(--radius-md);
|
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-muted);
|
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
background: none;
|
.status-bar { cursor: default; }
|
||||||
cursor: pointer;
|
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
}
|
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); white-space: nowrap; }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
.sel-text-btn:hover { color: var(--text-primary); }
|
||||||
|
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||||
|
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
|
||||||
|
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||||
|
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.status-bar {
|
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
display: flex;
|
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||||
align-items: center;
|
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||||
gap: var(--sp-3);
|
.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; }
|
||||||
padding: var(--sp-3);
|
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
background: var(--bg-raised);
|
.move-input:focus { color: var(--text-primary); }
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex: 1;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-eta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-count {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DownloadQueueItem } from "@types/index";
|
||||||
|
|
||||||
|
const RETRY_DELAY_MS = 20_000;
|
||||||
|
|
||||||
|
export interface AutoRetryHandle {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAutoRetry(
|
||||||
|
getQueue: () => DownloadQueueItem[],
|
||||||
|
isRunning: () => boolean,
|
||||||
|
retryErrored: () => Promise<void>,
|
||||||
|
): AutoRetryHandle {
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
const queue = getQueue();
|
||||||
|
const errored = queue.filter(i => i.state === "ERROR");
|
||||||
|
const active = queue.filter(i => i.state !== "ERROR");
|
||||||
|
|
||||||
|
if (errored.length > 0 && active.length === 0 && !isRunning()) {
|
||||||
|
await retryErrored().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -55,6 +55,16 @@ export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): nu
|
|||||||
return remaining / pagesPerSec;
|
return remaining / pagesPerSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reorderSelectedToEdge(
|
||||||
|
queue: DownloadQueueItem[],
|
||||||
|
selected: Set<number>,
|
||||||
|
edge: "top" | "bottom",
|
||||||
|
): DownloadQueueItem[] {
|
||||||
|
const pinned = queue.filter((i) => selected.has(i.chapter.id));
|
||||||
|
const rest = queue.filter((i) => !selected.has(i.chapter.id));
|
||||||
|
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||||
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
isRunning, getErrored, calcSpeed, estimateEta,
|
isRunning, getErrored, calcSpeed, estimateEta,
|
||||||
type SpeedSample,
|
type SpeedSample,
|
||||||
} from "../lib/downloadQueue";
|
} from "../lib/downloadQueue";
|
||||||
|
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||||
|
|
||||||
class DownloadStore {
|
class DownloadStore {
|
||||||
status: DownloadStatus | null = $state(null);
|
status: DownloadStatus | null = $state(null);
|
||||||
@@ -25,16 +26,38 @@ class DownloadStore {
|
|||||||
eta: number | null = $state(null);
|
eta: number | null = $state(null);
|
||||||
|
|
||||||
toastsEnabled = $state(true);
|
toastsEnabled = $state(true);
|
||||||
|
autoRetryEnabled = $state(false);
|
||||||
|
|
||||||
private lastSample: SpeedSample | null = null;
|
private lastSample: SpeedSample | null = null;
|
||||||
private prevQueue: DownloadQueueItem[] = [];
|
private prevQueue: DownloadQueueItem[] = [];
|
||||||
|
private autoRetryHnd: AutoRetryHandle | null = null;
|
||||||
|
|
||||||
get queue() { return this.status?.queue ?? []; }
|
get queue() { return this.status?.queue ?? []; }
|
||||||
get isRunning() { return isRunning(this.status?.state); }
|
get isRunning() { return isRunning(this.status?.state); }
|
||||||
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
||||||
get hasErrored() { return this.erroredIds.size > 0; }
|
get hasErrored() { return this.erroredIds.size > 0; }
|
||||||
|
|
||||||
toggleToasts() { this.toastsEnabled = !this.toastsEnabled; }
|
toggleToasts() {
|
||||||
|
this.toastsEnabled = !this.toastsEnabled;
|
||||||
|
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAutoRetry() {
|
||||||
|
if (this.autoRetryEnabled) {
|
||||||
|
this.autoRetryHnd?.stop();
|
||||||
|
this.autoRetryHnd = null;
|
||||||
|
this.autoRetryEnabled = false;
|
||||||
|
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
||||||
|
} else {
|
||||||
|
this.autoRetryEnabled = true;
|
||||||
|
this.autoRetryHnd = startAutoRetry(
|
||||||
|
() => this.queue,
|
||||||
|
() => this.isRunning,
|
||||||
|
() => this.retryAllErrored(),
|
||||||
|
);
|
||||||
|
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
detectTransitions(next: DownloadQueueItem[]) {
|
detectTransitions(next: DownloadQueueItem[]) {
|
||||||
if (!this.toastsEnabled) return;
|
if (!this.toastsEnabled) return;
|
||||||
@@ -101,7 +124,10 @@ class DownloadStore {
|
|||||||
this.applyStatus(d.startDownloader.downloadStatus);
|
this.applyStatus(d.startDownloader.downloadStatus);
|
||||||
}
|
}
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
finally { this.togglingPlay = false; }
|
finally {
|
||||||
|
this.togglingPlay = false;
|
||||||
|
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
@@ -113,6 +139,7 @@ class DownloadStore {
|
|||||||
try {
|
try {
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||||
this.applyStatus(d.clearDownloader.downloadStatus);
|
this.applyStatus(d.clearDownloader.downloadStatus);
|
||||||
|
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
finally { this.clearing = false; }
|
finally { this.clearing = false; }
|
||||||
}
|
}
|
||||||
@@ -137,6 +164,7 @@ class DownloadStore {
|
|||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
this.poll();
|
this.poll();
|
||||||
|
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
@@ -160,6 +188,7 @@ class DownloadStore {
|
|||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
this.poll();
|
this.poll();
|
||||||
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
@@ -173,6 +202,7 @@ class DownloadStore {
|
|||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
}
|
}
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
@@ -231,6 +261,60 @@ class DownloadStore {
|
|||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
|
||||||
|
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const first = this.isRunning ? 1 : 0;
|
||||||
|
const last = this.queue.length - 1;
|
||||||
|
const to = edge === "top" ? first : last;
|
||||||
|
if (idx === to) return;
|
||||||
|
const newQueue = [...this.queue];
|
||||||
|
newQueue.splice(idx, 1);
|
||||||
|
newQueue.splice(to, 0, this.queue[idx]);
|
||||||
|
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||||
|
try {
|
||||||
|
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||||
|
REORDER_DOWNLOAD, { chapterId, to },
|
||||||
|
);
|
||||||
|
this.applyStatus(d.reorderChapterDownload.downloadStatus);
|
||||||
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorderSelectedToEdge(edge: "top" | "bottom") {
|
||||||
|
if (this.batchWorking || this.selected.size === 0) return;
|
||||||
|
this.batchWorking = true;
|
||||||
|
|
||||||
|
const first = this.isRunning ? 1 : 0;
|
||||||
|
const active = this.queue.slice(0, first);
|
||||||
|
const moveable = this.queue.slice(first);
|
||||||
|
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
|
||||||
|
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
|
||||||
|
const newQueue = edge === "top"
|
||||||
|
? [...active, ...pinned, ...rest]
|
||||||
|
: [...active, ...rest, ...pinned];
|
||||||
|
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||||
|
|
||||||
|
const last = this.queue.length - 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (edge === "top") {
|
||||||
|
for (let i = 0; i < pinned.length; i++) {
|
||||||
|
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||||
|
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < pinned.length; i++) {
|
||||||
|
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||||
|
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.poll();
|
||||||
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
|
finally { this.batchWorking = false; }
|
||||||
|
}
|
||||||
|
|
||||||
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
||||||
toggleSelect(chapterId: number) {
|
toggleSelect(chapterId: number) {
|
||||||
const next = new Set(this.selected);
|
const next = new Set(this.selected);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
|
||||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
panel: Panel;
|
panel: Panel;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
updateCount: number;
|
updateCount: number;
|
||||||
|
updatingAll: boolean;
|
||||||
availableLangs: string[];
|
availableLangs: string[];
|
||||||
langFilter: string | null;
|
langFilter: string | null;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
@@ -18,14 +19,15 @@
|
|||||||
onLang: (lang: string | null) => void;
|
onLang: (lang: string | null) => void;
|
||||||
onPanel: (p: Panel) => void;
|
onPanel: (p: Panel) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onUpdateAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
filter, search, panel, refreshing, updateCount,
|
filter, search, panel, refreshing, updateCount, updatingAll,
|
||||||
availableLangs, langFilter,
|
availableLangs, langFilter,
|
||||||
anims, tabIndicator,
|
anims, tabIndicator,
|
||||||
tabsEl = $bindable(),
|
tabsEl = $bindable(),
|
||||||
onFilter, onSearch, onLang, onPanel, onRefresh,
|
onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,6 +59,11 @@
|
|||||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
|
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
{#if updateCount > 0}
|
||||||
|
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
|
||||||
|
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,6 +97,8 @@
|
|||||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
.icon-btn.update-badge { color: var(--accent-fg); }
|
||||||
|
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
|
||||||
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||||
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; 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); }
|
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; 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); }
|
||||||
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
if (!tabsEl) return;
|
if (!tabsEl) return;
|
||||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const containerLeft = tabsEl.getBoundingClientRect().left;
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
tabIndicator = { left: active.getBoundingClientRect().left - containerLeft, width: active.offsetWidth };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let extensions: Extension[] = $state([]);
|
let extensions: Extension[] = $state([]);
|
||||||
@@ -30,10 +29,11 @@
|
|||||||
let search = $state("");
|
let search = $state("");
|
||||||
let langFilter = $state<string | null>(null);
|
let langFilter = $state<string | null>(null);
|
||||||
let working = $state(new Set<string>());
|
let working = $state(new Set<string>());
|
||||||
|
let updatingAll = $state(false);
|
||||||
let expanded = $state(new Set<string>());
|
let expanded = $state(new Set<string>());
|
||||||
let panel = $state<Panel>(null);
|
let panel = $state<Panel>(null);
|
||||||
|
|
||||||
$effect(() => { filter; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||||
|
|
||||||
let externalUrl = $state("");
|
let externalUrl = $state("");
|
||||||
let installing = $state(false);
|
let installing = $state(false);
|
||||||
@@ -125,6 +125,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateAll() {
|
||||||
|
const pending = extensions.filter((e) => e.hasUpdate);
|
||||||
|
if (!pending.length || updatingAll) return;
|
||||||
|
updatingAll = true;
|
||||||
|
for (const ext of pending) await mutate(ext.pkgName, "update");
|
||||||
|
updatingAll = false;
|
||||||
|
addToast({ kind: "success", title: "All extensions updated", body: `${pending.length} extension${pending.length === 1 ? "" : "s"} updated` });
|
||||||
|
}
|
||||||
|
|
||||||
async function installExternal() {
|
async function installExternal() {
|
||||||
const url = externalUrl.trim();
|
const url = externalUrl.trim();
|
||||||
const err = validateUrl(url, ".apk");
|
const err = validateUrl(url, ".apk");
|
||||||
@@ -207,13 +216,14 @@
|
|||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
<ExtensionFilters
|
<ExtensionFilters
|
||||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||||
{anims} {tabIndicator}
|
{anims} {tabIndicator} {updatingAll}
|
||||||
bind:tabsEl
|
bind:tabsEl
|
||||||
onFilter={setFilter}
|
onFilter={setFilter}
|
||||||
onSearch={(q) => search = q}
|
onSearch={(q) => search = q}
|
||||||
onLang={(l) => langFilter = l}
|
onLang={(l) => langFilter = l}
|
||||||
onPanel={openPanel}
|
onPanel={openPanel}
|
||||||
onRefresh={fetchFromRepo}
|
onRefresh={fetchFromRepo}
|
||||||
|
onUpdateAll={updateAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if panel === "apk"}
|
{#if panel === "apk"}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
dailyReadCounts,
|
||||||
|
}: {
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
||||||
|
if (count === 0) return 0;
|
||||||
|
if (count === 1) return 1;
|
||||||
|
if (count <= 3) return 2;
|
||||||
|
if (count <= 6) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tip: { text: string; x: number; y: number } | null = $state(null);
|
||||||
|
|
||||||
|
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const label = cell.count === 0
|
||||||
|
? `No chapters — ${fmtDate(cell.dateStr)}`
|
||||||
|
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
||||||
|
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTip() { tip = null; }
|
||||||
|
|
||||||
|
function fmtDate(d: string): string {
|
||||||
|
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapEl: HTMLElement;
|
||||||
|
let cellSize = $state(12);
|
||||||
|
let numWeeks = $state(26);
|
||||||
|
|
||||||
|
const GAP = 3;
|
||||||
|
const DAY_GUTTER = 28;
|
||||||
|
const LEGEND_H = 20;
|
||||||
|
const MONTH_H = 14;
|
||||||
|
const ROWS = 7;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!wrapEl) return;
|
||||||
|
const obs = new ResizeObserver(() => {
|
||||||
|
const h = wrapEl.clientHeight;
|
||||||
|
const w = wrapEl.clientWidth;
|
||||||
|
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
||||||
|
cellSize = cs;
|
||||||
|
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
||||||
|
});
|
||||||
|
obs.observe(wrapEl);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleWeeks = $derived((() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||||
|
const weekEnd = new Date(today);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||||
|
|
||||||
|
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
||||||
|
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
||||||
|
const week: typeof weeks[0] = [];
|
||||||
|
for (let di = 0; di < 7; di++) {
|
||||||
|
const d = new Date(weekEnd);
|
||||||
|
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const monthLabels = $derived((() => {
|
||||||
|
const labels: { label: string; colIndex: number }[] = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
visibleWeeks.forEach((week, ci) => {
|
||||||
|
const first = week[0];
|
||||||
|
if (!first) return;
|
||||||
|
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
||||||
|
if (m !== lastMonth) {
|
||||||
|
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return labels;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
||||||
|
|
||||||
|
<div class="month-row">
|
||||||
|
<div class="day-gutter"></div>
|
||||||
|
<div class="month-cells">
|
||||||
|
{#each visibleWeeks as _week, ci}
|
||||||
|
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
||||||
|
<div class="month-label">{lbl?.label ?? ""}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="day-labels">
|
||||||
|
{#each DAY_LABELS as d}
|
||||||
|
<span class="day-label">{d}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="cell-grid">
|
||||||
|
{#each visibleWeeks as week}
|
||||||
|
<div class="week-col">
|
||||||
|
{#each week as cell}
|
||||||
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
|
<button
|
||||||
|
class="cell intensity-{intensity(cell.count)}"
|
||||||
|
class:cell-today={cell.isToday}
|
||||||
|
class:cell-future={cell.isFuture}
|
||||||
|
onmouseover={(e) => showTip(e, cell)}
|
||||||
|
onmouseleave={hideTip}
|
||||||
|
aria-label="{cell.count} chapters on {cell.dateStr}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-label">Less</span>
|
||||||
|
{#each [0, 1, 2, 3, 4] as lvl}
|
||||||
|
<div class="legend-cell intensity-{lvl}"></div>
|
||||||
|
{/each}
|
||||||
|
<span class="legend-label">More</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tip}
|
||||||
|
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.heatmap-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.day-gutter { width: 28px; flex-shrink: 0; }
|
||||||
|
.month-cells {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||||
|
gap: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.month-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding-left: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.day-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
.day-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
height: var(--cell);
|
||||||
|
line-height: var(--cell);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||||
|
gap: 3px;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
|
}
|
||||||
|
.week-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: var(--cell);
|
||||||
|
height: var(--cell);
|
||||||
|
border-radius: 3px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter var(--t-fast), transform var(--t-fast);
|
||||||
|
}
|
||||||
|
.cell:hover:not(.cell-future) {
|
||||||
|
filter: brightness(1.5);
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
||||||
|
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
||||||
|
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
||||||
|
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
||||||
|
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
||||||
|
|
||||||
|
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
||||||
|
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.legend-cell {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.legend-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-tip {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
import HeroStage from "./HeroStage.svelte";
|
import HeroStage from "./HeroStage.svelte";
|
||||||
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
||||||
import ActivityFeed from "./ActivityFeed.svelte";
|
import ActivityFeed from "./ActivityFeed.svelte";
|
||||||
import UpdatesRow from "./UpdatesRow.svelte";
|
import ActivityHeatmap from "./ActivityHeatmap.svelte";
|
||||||
|
import RecsRow from "./RecsRow.svelte";
|
||||||
import StatsGrid from "./StatsGrid.svelte";
|
import StatsGrid from "./StatsGrid.svelte";
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
@@ -223,6 +224,7 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
|
||||||
|
<div class="hero-shrink-guard">
|
||||||
<HeroStage
|
<HeroStage
|
||||||
{resolvedSlots}
|
{resolvedSlots}
|
||||||
bind:activeIdx
|
bind:activeIdx
|
||||||
@@ -243,25 +245,39 @@
|
|||||||
onunpin={unpinSlot}
|
onunpin={unpinSlot}
|
||||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-body">
|
||||||
|
<div class="mid-row">
|
||||||
|
<div class="mid-left">
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
entries={recentHistory}
|
entries={recentHistory}
|
||||||
onresume={resumeEntry}
|
onresume={resumeEntry}
|
||||||
onviewhistory={() => setNavPage("history")}
|
onviewhistory={() => setNavPage("history")}
|
||||||
onopenlibrary={() => setNavPage("library")}
|
onopenlibrary={() => setNavPage("library")}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mid-divider"></div>
|
||||||
|
<div class="mid-right">
|
||||||
|
<RecsRow
|
||||||
|
{libraryManga}
|
||||||
|
history={store.history}
|
||||||
|
onopenrecommended={(m) => { store.previewManga = m; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bottom-row">
|
<div class="bottom-row">
|
||||||
<UpdatesRow
|
<div class="bottom-heatmap">
|
||||||
updates={libraryUpdates}
|
<span class="bottom-label">Activity</span>
|
||||||
{libraryManga}
|
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
|
||||||
{lastRefresh}
|
</div>
|
||||||
onopen={(m) => { if (m) store.previewManga = m; }}
|
|
||||||
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
|
|
||||||
/>
|
|
||||||
<div class="bottom-divider"></div>
|
<div class="bottom-divider"></div>
|
||||||
|
<div class="bottom-stats">
|
||||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,19 +304,65 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.hero-shrink-guard { flex-shrink: 0; }
|
||||||
|
.scroll-body {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.scroll-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.mid-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1px 1.4fr;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.mid-left {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* suppress ActivityFeed's own border-top — mid-row provides it */
|
||||||
|
.mid-left :global(.section) { border-top: none; }
|
||||||
|
.mid-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
|
.mid-right {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-row {
|
.bottom-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1px 1fr;
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
|
.bottom-heatmap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bottom-stats {
|
||||||
|
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bottom-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
import { fetchRecommendations, topGenres } from "../lib/recommendations";
|
||||||
|
import type { RecommendedManga } from "../lib/recommendations";
|
||||||
|
|
||||||
|
let {
|
||||||
|
libraryManga,
|
||||||
|
history,
|
||||||
|
onopenrecommended,
|
||||||
|
}: {
|
||||||
|
libraryManga: Manga[];
|
||||||
|
history: HistoryEntry[];
|
||||||
|
onopenrecommended: (m: Manga) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const CARD_MIN_WIDTH = 100;
|
||||||
|
const GAP = 12;
|
||||||
|
const ROWS = 2;
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
containerWidth = entry.contentRect.width;
|
||||||
|
});
|
||||||
|
ro.observe(containerEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
|
||||||
|
const visibleCount = $derived(cols * ROWS);
|
||||||
|
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
|
||||||
|
|
||||||
|
let allRecs: RecommendedManga[] = $state([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let _ctrl: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _history = history;
|
||||||
|
const _library = libraryManga;
|
||||||
|
if (!_history.length || !_library.length) { allRecs = []; return; }
|
||||||
|
_ctrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
_ctrl = ctrl;
|
||||||
|
loading = true;
|
||||||
|
fetchRecommendations(_history, _library, ctrl.signal)
|
||||||
|
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
|
||||||
|
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const genres = $derived(topGenres(history, libraryManga));
|
||||||
|
|
||||||
|
let genreIdx = $state(0);
|
||||||
|
|
||||||
|
const activeGenre = $derived(genres[genreIdx] ?? null);
|
||||||
|
|
||||||
|
const visibleRecs = $derived(
|
||||||
|
(activeGenre
|
||||||
|
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
|
||||||
|
: allRecs
|
||||||
|
).slice(0, visibleCount)
|
||||||
|
);
|
||||||
|
|
||||||
|
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
|
||||||
|
function next() { genreIdx = (genreIdx + 1) % genres.length; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header">
|
||||||
|
<span class="col-title">
|
||||||
|
<Sparkle size={10} weight="bold" /> Recommended
|
||||||
|
</span>
|
||||||
|
{#if genres.length > 1}
|
||||||
|
<div class="genre-switcher">
|
||||||
|
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
|
||||||
|
<span class="genre-label">{activeGenre}</span>
|
||||||
|
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-container" bind:this={containerEl}>
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty-msg">Loading…</p>
|
||||||
|
{:else if visibleRecs.length > 0}
|
||||||
|
<div class="card-grid" style={gridStyle}>
|
||||||
|
{#each visibleRecs as r (r.manga.id)}
|
||||||
|
<button class="card" onclick={() => onopenrecommended(r.manga)}>
|
||||||
|
<div class="card-cover-wrap">
|
||||||
|
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
|
||||||
|
<div class="card-gradient"></div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<p class="card-title">{r.manga.title}</p>
|
||||||
|
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-msg">No recommendations found</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
|
||||||
|
|
||||||
|
.col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.genre-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.nav-btn:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.grid-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, auto);
|
||||||
|
grid-auto-rows: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
||||||
|
|
||||||
|
.card-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);
|
||||||
|
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
:global(.card-cover) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: filter 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.card-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.card-badge {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
||||||
import { formatReadTime } from "../lib/homeHelpers";
|
import { formatReadTime } from "../lib/homeHelpers";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
stats,
|
stats,
|
||||||
updateCount,
|
updateCount,
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Bell, ArrowRight } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
import { timeAgoRefresh, handleRowWheel } from "../lib/homeHelpers";
|
|
||||||
|
|
||||||
interface LibraryUpdate {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
newChapters: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
updates,
|
|
||||||
libraryManga,
|
|
||||||
lastRefresh,
|
|
||||||
onopen,
|
|
||||||
onclear,
|
|
||||||
}: {
|
|
||||||
updates: LibraryUpdate[];
|
|
||||||
libraryManga: Manga[];
|
|
||||||
lastRefresh: number;
|
|
||||||
onopen: (m: Manga | undefined) => void;
|
|
||||||
onclear: () => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-title">
|
|
||||||
<Bell size={10} weight="bold" /> Updates
|
|
||||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
|
||||||
</span>
|
|
||||||
{#if updates.length > 0}
|
|
||||||
<button class="action-btn" onclick={onclear}>
|
|
||||||
Clear <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if updates.length > 0}
|
|
||||||
<div class="scroll-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
|
||||||
{#each updates as u (u.mangaId)}
|
|
||||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
|
||||||
<button class="card" onclick={() => onopen(m)}>
|
|
||||||
<div class="card-cover-wrap">
|
|
||||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="card-cover" />
|
|
||||||
<div class="card-gradient"></div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{u.mangaTitle}</p>
|
|
||||||
<p class="card-badge">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty-msg">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.col { display: flex; flex-direction: column; min-width: 0; }
|
|
||||||
|
|
||||||
.col-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-bottom: var(--sp-2);
|
|
||||||
}
|
|
||||||
.col-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.refresh-age {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: var(--sp-2);
|
|
||||||
}
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.action-btn:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.scroll-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-bottom: var(--sp-1);
|
|
||||||
}
|
|
||||||
.scroll-row::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
flex: 0 0 112px;
|
|
||||||
width: 112px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
|
||||||
|
|
||||||
.card-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);
|
|
||||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
|
|
||||||
}
|
|
||||||
:global(.card-cover) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
transition: filter 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
.card-gradient {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.card-footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
.card-badge {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-msg {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-1) 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { gql } from "@api/client";
|
||||||
|
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||||
|
import { buildTagFilter } from "@features/discover/lib/searchFilter";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
|
||||||
|
export interface RecommendedManga {
|
||||||
|
manga: Manga;
|
||||||
|
matchedGenres: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_GENRES = 6;
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
const MAX_PAGES = 5;
|
||||||
|
|
||||||
|
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
|
||||||
|
const byId = new Map(libraryManga.map(m => [m.id, m]));
|
||||||
|
const tally = new Map<string, { count: number; original: string }>();
|
||||||
|
|
||||||
|
for (const entry of history) {
|
||||||
|
const manga = byId.get(entry.mangaId);
|
||||||
|
if (!manga?.genre?.length) continue;
|
||||||
|
for (const g of manga.genre) {
|
||||||
|
const key = g.toLowerCase();
|
||||||
|
const existing = tally.get(key);
|
||||||
|
if (existing) { existing.count++; }
|
||||||
|
else { tally.set(key, { count: 1, original: g }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tally.values()]
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, TOP_GENRES)
|
||||||
|
.map(e => e.original);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result = { mangas: { nodes: Manga[] } };
|
||||||
|
|
||||||
|
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> {
|
||||||
|
const filter = {
|
||||||
|
and: [
|
||||||
|
buildTagFilter([genre], "OR", []),
|
||||||
|
{ inLibrary: { equalTo: false } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
Array.from({ length: MAX_PAGES }, (_, i) =>
|
||||||
|
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
|
||||||
|
.then(d => d.mangas.nodes)
|
||||||
|
.catch(() => [] as Manga[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const nodes: Manga[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
if (!page.length) break;
|
||||||
|
for (const m of page) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
|
||||||
|
}
|
||||||
|
if (page.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRecommendations(
|
||||||
|
history: HistoryEntry[],
|
||||||
|
libraryManga: Manga[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RecommendedManga[]> {
|
||||||
|
if (!history.length || !libraryManga.length) return [];
|
||||||
|
|
||||||
|
const genres = topGenres(history, libraryManga);
|
||||||
|
if (!genres.length) return [];
|
||||||
|
|
||||||
|
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal)));
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const page of perGenre) {
|
||||||
|
for (const m of page) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.map(m => ({
|
||||||
|
manga: m,
|
||||||
|
matchedGenres: (m.genre ?? []).filter(g =>
|
||||||
|
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
import LibraryToolbar from "./LibraryToolbar.svelte";
|
import LibraryToolbar from "./LibraryToolbar.svelte";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
|
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
|
||||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||||
|
|
||||||
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
|
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
let selectedIds: Set<number> = $state(new Set());
|
let selectedIds: Set<number> = $state(new Set());
|
||||||
let selectMode: boolean = $state(false);
|
let selectMode: boolean = $state(false);
|
||||||
let bulkWorking: boolean = $state(false);
|
let bulkWorking: boolean = $state(false);
|
||||||
let bulkMoveOpen: boolean = $state(false);
|
let bulkAutomateOpen: boolean = $state(false);
|
||||||
|
|
||||||
let sortPanelOpen: boolean = $state(false);
|
let sortPanelOpen: boolean = $state(false);
|
||||||
let filterPanelOpen: boolean = $state(false);
|
let filterPanelOpen: boolean = $state(false);
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
let dragInsertIdx: number = $state(-1);
|
let dragInsertIdx: number = $state(-1);
|
||||||
let dragTabId: number | null = $state(null);
|
let dragTabId: number | null = $state(null);
|
||||||
let dragOverTabId: number | null = $state(null);
|
let dragOverTabId: number | null = $state(null);
|
||||||
let dropTargetTabId: number | null = $state(null);
|
|
||||||
|
|
||||||
const DT_TAB = "application/x-moku-tab";
|
const DT_TAB = "application/x-moku-tab";
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
@@ -105,6 +106,14 @@
|
|||||||
items = (store.settings.libraryShowAllInSaved ?? true)
|
items = (store.settings.libraryShowAllInSaved ?? true)
|
||||||
? allManga.filter(m => m.inLibrary)
|
? allManga.filter(m => m.inLibrary)
|
||||||
: (categoryMangaMap.get(0) ?? []);
|
: (categoryMangaMap.get(0) ?? []);
|
||||||
|
|
||||||
|
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
|
||||||
|
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
|
||||||
|
if (completedCat) {
|
||||||
|
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
|
||||||
|
items = items.filter(m => !completedIds.has(m.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (tab === "downloaded") {
|
} else if (tab === "downloaded") {
|
||||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
|
||||||
});
|
});
|
||||||
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
$effect(() => { tab; untrack(() => exitSelectMode()); });
|
||||||
$effect(() => { tab; setTimeout(updateTabIndicator); });
|
$effect(() => { tab; counts; requestAnimationFrame(updateTabIndicator); });
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -179,13 +188,11 @@
|
|||||||
if (!tabsEl) return;
|
if (!tabsEl) return;
|
||||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const parent = tabsEl.getBoundingClientRect();
|
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||||
const rect = active.getBoundingClientRect();
|
|
||||||
tabIndicator = { left: rect.left - parent.left, width: rect.width };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
|
||||||
function exitSelectMode() { selectMode = false; selectedIds = new Set(); bulkMoveOpen = false; }
|
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
|
||||||
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
|
||||||
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
||||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||||
@@ -299,7 +306,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bulkMoveToCategory(cat: Category) {
|
async function bulkMoveToCategory(cat: Category) {
|
||||||
bulkWorking = true; bulkMoveOpen = false;
|
bulkWorking = true;
|
||||||
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
|
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
|
||||||
finally { bulkWorking = false; exitSelectMode(); }
|
finally { bulkWorking = false; exitSelectMode(); }
|
||||||
}
|
}
|
||||||
@@ -310,6 +317,11 @@
|
|||||||
finally { bulkWorking = false; exitSelectMode(); }
|
finally { bulkWorking = false; exitSelectMode(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bulkAutomate() {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
bulkAutomateOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
|
||||||
|
|
||||||
async function openMangaFolder(m: Manga) {
|
async function openMangaFolder(m: Manga) {
|
||||||
@@ -412,26 +424,30 @@
|
|||||||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
||||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
||||||
dragOverTabId = cat.id; dragInsertIdx = idx;
|
dragOverTabId = cat.id;
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragLeave() { dragOverTabId = null; }
|
function onTabDragLeave() { dragOverTabId = null; }
|
||||||
|
|
||||||
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
||||||
e.preventDefault(); dragOverTabId = null; dragInsertIdx = -1;
|
e.preventDefault(); dragOverTabId = null;
|
||||||
|
const insertAt = dragInsertIdx;
|
||||||
|
dragInsertIdx = -1;
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
||||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||||
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
||||||
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
||||||
const toIdx = sorted.findIndex(c => c.id === dropCat.id);
|
if (fromIdx < 0) return;
|
||||||
if (fromIdx < 0 || toIdx < 0) return;
|
|
||||||
const reordered = [...sorted];
|
const reordered = [...sorted];
|
||||||
const [moved] = reordered.splice(fromIdx, 1);
|
const [moved] = reordered.splice(fromIdx, 1);
|
||||||
reordered.splice(toIdx, 0, moved);
|
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, reordered.length));
|
||||||
|
reordered.splice(dest, 0, moved);
|
||||||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
||||||
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
||||||
try {
|
try {
|
||||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: toIdx + 1 });
|
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: dest + 1 });
|
||||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +475,7 @@
|
|||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
document.addEventListener("mousedown", onDocMouseDown, true);
|
document.addEventListener("mousedown", onDocMouseDown, true);
|
||||||
updateTabIndicator();
|
requestAnimationFrame(updateTabIndicator);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect(); unsub();
|
ro.disconnect(); unsub();
|
||||||
@@ -509,6 +525,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<LibraryToolbar
|
<LibraryToolbar
|
||||||
|
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
|
||||||
{tab}
|
{tab}
|
||||||
{tabSortMode}
|
{tabSortMode}
|
||||||
{tabSortDir}
|
{tabSortDir}
|
||||||
@@ -567,6 +584,7 @@
|
|||||||
{remainingCount}
|
{remainingCount}
|
||||||
renderLimit={store.settings.renderLimit ?? 48}
|
renderLimit={store.settings.renderLimit ?? 48}
|
||||||
cropCovers={store.settings.libraryCropCovers}
|
cropCovers={store.settings.libraryCropCovers}
|
||||||
|
statsAlways={store.settings.libraryStatsAlways ?? false}
|
||||||
libraryFilter={tab}
|
libraryFilter={tab}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
onCardContextMenu={openCtx}
|
onCardContextMenu={openCtx}
|
||||||
@@ -577,12 +595,11 @@
|
|||||||
onRetry={() => retryCount++}
|
onRetry={() => retryCount++}
|
||||||
onExitSelectMode={exitSelectMode}
|
onExitSelectMode={exitSelectMode}
|
||||||
onSelectAll={selectAll}
|
onSelectAll={selectAll}
|
||||||
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }}
|
onBulkMove={bulkMoveToCategory}
|
||||||
onBulkRemove={bulkRemoveFromLibrary}
|
onBulkRemove={bulkRemoveFromLibrary}
|
||||||
|
onBulkAutomate={bulkAutomate}
|
||||||
{bulkWorking}
|
{bulkWorking}
|
||||||
{bulkMoveOpen}
|
|
||||||
{visibleCategories}
|
{visibleCategories}
|
||||||
onCategoryMove={bulkMoveToCategory}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -593,6 +610,12 @@
|
|||||||
{#if emptyCtx}
|
{#if emptyCtx}
|
||||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if bulkAutomateOpen}
|
||||||
|
<BulkAutomationPanel
|
||||||
|
ids={selectedIds}
|
||||||
|
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder, Trash, CheckSquare, X } from "phosphor-svelte";
|
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { Manga, Category } from "@types";
|
import type { Manga, Category } from "@types";
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
remainingCount: number;
|
remainingCount: number;
|
||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
cropCovers: boolean;
|
cropCovers: boolean;
|
||||||
|
statsAlways: boolean;
|
||||||
libraryFilter: string;
|
libraryFilter: string;
|
||||||
bulkWorking: boolean;
|
bulkWorking: boolean;
|
||||||
bulkMoveOpen: boolean;
|
|
||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
onCardClick: (e: MouseEvent, m: Manga) => void;
|
onCardClick: (e: MouseEvent, m: Manga) => void;
|
||||||
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
|
||||||
@@ -30,42 +30,51 @@
|
|||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onBulkMove: (cat: Category) => void;
|
onBulkMove: (cat: Category) => void;
|
||||||
onBulkRemove: () => void;
|
onBulkRemove: () => void;
|
||||||
onCategoryMove: (cat: Category) => void;
|
onBulkAutomate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
|
||||||
hasMore, remainingCount, renderLimit, cropCovers, libraryFilter,
|
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
|
||||||
bulkWorking, bulkMoveOpen, visibleCategories,
|
bulkWorking, visibleCategories,
|
||||||
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
|
||||||
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onCategoryMove,
|
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let bulkMoveOpen: boolean = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!bulkMoveOpen) return;
|
||||||
|
function onOutside(e: MouseEvent) {
|
||||||
|
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
|
||||||
|
}
|
||||||
|
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||||
|
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if selectMode}
|
{#if selectMode}
|
||||||
<div class="select-bar">
|
<div class="select-bar">
|
||||||
<div class="select-bar-left">
|
|
||||||
<button class="sel-btn sel-cancel" onclick={onExitSelectMode} title="Cancel (Esc)">
|
|
||||||
<X size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
<span class="sel-count">{selectedIds.size} selected</span>
|
<span class="sel-count">{selectedIds.size} selected</span>
|
||||||
<button class="sel-btn sel-all" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
<button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
|
||||||
</div>
|
|
||||||
<div class="select-bar-right">
|
<div class="select-bar-right">
|
||||||
{#if visibleCategories.length}
|
{#if visibleCategories.length}
|
||||||
<div class="bulk-move-wrap">
|
<div class="bulk-move-wrap">
|
||||||
<button
|
<button
|
||||||
class="sel-btn sel-move"
|
class="sel-action-btn"
|
||||||
disabled={selectedIds.size === 0 || bulkWorking}
|
disabled={selectedIds.size === 0 || bulkWorking}
|
||||||
onclick={() => onBulkMove(visibleCategories[0])}
|
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
||||||
>
|
>
|
||||||
<Folder size={13} weight="bold" />
|
<Folder size={13} weight="bold" />
|
||||||
Move to folder
|
Move
|
||||||
</button>
|
</button>
|
||||||
{#if bulkMoveOpen}
|
{#if bulkMoveOpen}
|
||||||
<div class="bulk-folder-list">
|
<div class="bulk-folder-list">
|
||||||
{#each visibleCategories as cat}
|
{#each visibleCategories as cat}
|
||||||
<button class="bulk-folder-item" onclick={() => onCategoryMove(cat)}>
|
<button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
|
||||||
<Folder size={11} weight="bold" />
|
<Folder size={11} weight="bold" />
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -74,7 +83,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="sel-btn sel-remove" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
<button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
|
||||||
|
<Robot size={13} weight="bold" />
|
||||||
|
Automate
|
||||||
|
</button>
|
||||||
|
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
|
||||||
<Trash size={13} weight="bold" />
|
<Trash size={13} weight="bold" />
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -82,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="content">
|
<div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each Array(12) as _}
|
{#each Array(12) as _}
|
||||||
@@ -116,24 +129,18 @@
|
|||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims}>
|
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||||
|
<div class="overlay-badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
<span class="info-chip info-chip-done">✓ complete</span>
|
<span class="badge badge-done">✓ Done</span>
|
||||||
{:else if m.unreadCount}
|
{:else if m.unreadCount}
|
||||||
<span class="info-chip info-chip-unread">
|
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||||
<span class="info-chip-dot"></span>
|
|
||||||
{m.unreadCount} unread
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if m.downloadCount}
|
{#if m.downloadCount}
|
||||||
<span class="info-chip info-chip-dl">
|
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||||
<span class="info-chip-dot"></span>
|
|
||||||
{m.downloadCount}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{#if selectMode}
|
{#if selectMode}
|
||||||
<div class="select-overlay" aria-hidden="true">
|
<div class="select-overlay" aria-hidden="true">
|
||||||
<div class="select-check" class:checked={isSelected}>
|
<div class="select-check" class:checked={isSelected}>
|
||||||
@@ -163,22 +170,17 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||||
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
.select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
|
||||||
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); }
|
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
|
||||||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; }
|
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
||||||
.sel-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
.sel-text-btn:hover { color: var(--text-primary); }
|
||||||
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
.sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||||
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.sel-cancel { border-color: transparent; background: transparent; }
|
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
|
||||||
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
|
|
||||||
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
|
|
||||||
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
|
|
||||||
.sel-all { border-color: transparent; background: transparent; }
|
|
||||||
.bulk-move-wrap { position: relative; }
|
.bulk-move-wrap { position: relative; }
|
||||||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
|
||||||
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||||
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
|
||||||
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||||
@@ -193,15 +195,16 @@
|
|||||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||||
.card.anims .cover { transition: filter var(--t-base); }
|
.card.anims .cover { transition: filter var(--t-base); }
|
||||||
.card-info-overlay { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 20px 5px 5px; background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.3) 55%, transparent 100%); opacity: 0; transform: translateY(3px); pointer-events: none; }
|
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||||
.card-info-overlay.anim { transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16,1,0.3,1); }
|
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||||
.card-info-overlay.instant { transition: none; }
|
.card-info-overlay.instant { transition: none; }
|
||||||
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; transform: translateY(0); }
|
.card-info-overlay.always { opacity: 1; }
|
||||||
.info-chip { display: flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; line-height: 1; padding: 3px 6px; border-radius: 4px; background: rgba(0,0,0,0.52); backdrop-filter: blur(6px); }
|
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
|
||||||
.info-chip-unread { color: #fff; }
|
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||||
.info-chip-done { color: var(--accent-fg); font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; }
|
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||||
.info-chip-dl { color: var(--accent-fg); }
|
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||||
.info-chip-dot { width: 4px; height: 4px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
.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; }
|
||||||
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
|
||||||
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
|
||||||
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
.select-check.checked { color: var(--accent-fg); opacity: 1; }
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if visibleCategories.length > 0}
|
||||||
|
<div class="tab-separator" aria-hidden="true"></div>
|
||||||
|
<div class="tabs-scroll">
|
||||||
{#each visibleCategories as cat, idx}
|
{#each visibleCategories as cat, idx}
|
||||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
@@ -99,7 +102,6 @@
|
|||||||
class="tab"
|
class="tab"
|
||||||
class:active={tab === String(cat.id)}
|
class:active={tab === String(cat.id)}
|
||||||
class:tab-dragging={dragTabId === cat.id}
|
class:tab-dragging={dragTabId === cat.id}
|
||||||
class:tab-drop-target={dragOverTabId === cat.id}
|
|
||||||
draggable="true"
|
draggable="true"
|
||||||
onclick={() => onTabChange(String(cat.id))}
|
onclick={() => onTabChange(String(cat.id))}
|
||||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
ondragstart={(e) => onTabDragStart(e, cat)}
|
||||||
@@ -117,6 +119,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
@@ -195,17 +199,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; }
|
||||||
|
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
||||||
|
.tabs-scroll::-webkit-scrollbar { display: none; }
|
||||||
|
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
|
||||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; }
|
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
|
||||||
.tabs-anims .tab.active { background: transparent; border-color: transparent; }
|
.tabs-anims .tab.active { background: transparent; }
|
||||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||||
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
|
|
||||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
|
|||||||
|
|
||||||
if (!jobsInfo.isRunning && seenWork) {
|
if (!jobsInfo.isRunning && seenWork) {
|
||||||
const recent = await gql<{
|
const recent = await gql<{
|
||||||
chapters: { nodes: { mangaId: number; mangaTitle: string; thumbnailUrl: string; fetchedAt: string }[] }
|
chapters: {
|
||||||
|
nodes: {
|
||||||
|
mangaId: number;
|
||||||
|
fetchedAt: string;
|
||||||
|
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
|
||||||
|
}[]
|
||||||
|
}
|
||||||
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
|
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -79,8 +85,8 @@ export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => vo
|
|||||||
} else {
|
} else {
|
||||||
byManga.set(ch.mangaId, {
|
byManga.set(ch.mangaId, {
|
||||||
mangaId: ch.mangaId,
|
mangaId: ch.mangaId,
|
||||||
mangaTitle: ch.mangaTitle,
|
mangaTitle: ch.manga.title,
|
||||||
thumbnailUrl: ch.thumbnailUrl,
|
thumbnailUrl: ch.manga.thumbnailUrl,
|
||||||
newChapters: 1,
|
newChapters: 1,
|
||||||
checkedAt: Date.now(),
|
checkedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from "phosphor-svelte";
|
||||||
|
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
|
|
||||||
|
let { ids, onClose }: {
|
||||||
|
ids: Set<number>;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const DOWNLOAD_AHEAD_OPTIONS = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 2, label: "2" },
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_KEEP_OPTIONS = [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 5, label: "5" },
|
||||||
|
{ value: 10, label: "10" },
|
||||||
|
{ value: 25, label: "25" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DELETE_DELAY_OPTIONS = [
|
||||||
|
{ value: 0, label: "Now" },
|
||||||
|
{ value: 24, label: "1 day" },
|
||||||
|
{ value: 168, label: "1 week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_OPTIONS = [
|
||||||
|
{ value: "global", label: "Default" },
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "manual", label: "Manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let draft: MangaPrefs = $state({ ...DEFAULT_MANGA_PREFS });
|
||||||
|
|
||||||
|
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
|
||||||
|
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
for (const id of ids) {
|
||||||
|
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
|
||||||
|
setPref(id, key, draft[key] as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="modal-title">Automation</span>
|
||||||
|
<span class="modal-subtitle">{ids.size} series selected</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<p class="section-label">Downloads</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Auto-download new chapters</span>
|
||||||
|
<span class="auto-desc">Queue new chapters when this series refreshes</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("autoDownload")}
|
||||||
|
aria-label="Auto-download new chapters"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("autoDownload")}
|
||||||
|
onclick={() => set("autoDownload", !get("autoDownload"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Download ahead</span>
|
||||||
|
<span class="auto-desc">Pre-fetch chapters while reading</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("downloadAhead") === opt.value}
|
||||||
|
onclick={() => set("downloadAhead", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Max chapters to keep</span>
|
||||||
|
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each MAX_KEEP_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("maxKeepChapters") === opt.value}
|
||||||
|
onclick={() => set("maxKeepChapters", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="section-label">On Read</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Delete after reading</span>
|
||||||
|
<span class="auto-desc">Remove download when chapter is marked read</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("deleteOnRead")}
|
||||||
|
aria-label="Delete after reading"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("deleteOnRead")}
|
||||||
|
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if get("deleteOnRead")}
|
||||||
|
<div class="auto-row auto-row-sub">
|
||||||
|
<span class="auto-label">Delete delay</span>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each DELETE_DELAY_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("deleteDelayHours") === opt.value}
|
||||||
|
onclick={() => set("deleteDelayHours", opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="section-label">Updates</p>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Pause updates</span>
|
||||||
|
<span class="auto-desc">Skip this series during global refresh</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={get("pauseUpdates")}
|
||||||
|
aria-label="Pause updates"
|
||||||
|
class="auto-toggle"
|
||||||
|
class:auto-toggle-on={get("pauseUpdates")}
|
||||||
|
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
|
||||||
|
><span class="auto-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auto-row">
|
||||||
|
<div class="auto-info">
|
||||||
|
<span class="auto-label">Refresh interval</span>
|
||||||
|
<span class="auto-desc">How often to check for new chapters</span>
|
||||||
|
</div>
|
||||||
|
<div class="auto-chip-group">
|
||||||
|
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||||
|
<button
|
||||||
|
class="auto-chip"
|
||||||
|
class:auto-chip-on={get("refreshInterval") === opt.value}
|
||||||
|
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="apply-btn" onclick={apply}>Apply to {ids.size} series</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 300;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 420px; max-width: calc(100vw - var(--sp-6));
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl); overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
|
animation: scaleIn 0.15s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-left { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||||
|
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-5);
|
||||||
|
}
|
||||||
|
.modal-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.apply-btn {
|
||||||
|
width: 100%; padding: 8px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide); cursor: pointer;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.apply-btn:hover { background: var(--accent-dim); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-widest); color: var(--text-faint);
|
||||||
|
text-transform: uppercase; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
|
||||||
|
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||||
|
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||||
|
|
||||||
|
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||||
|
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
|
||||||
|
|
||||||
|
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import type { StripChapter } from "../lib/scrollHandler";
|
import type { StripChapter } from "../lib/scrollHandler";
|
||||||
|
import { createPinchTracker } from "../lib/pinchZoom";
|
||||||
|
import type { PinchTracker } from "../lib/pinchZoom";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: string;
|
style: string;
|
||||||
@@ -16,6 +18,9 @@
|
|||||||
stripToRender: StripChapter[];
|
stripToRender: StripChapter[];
|
||||||
fadingOut: boolean;
|
fadingOut: boolean;
|
||||||
tapToToggleBar: boolean;
|
tapToToggleBar: boolean;
|
||||||
|
pinchZoomEnabled: boolean;
|
||||||
|
onGetZoom: () => number;
|
||||||
|
onSetZoom: (z: number) => void;
|
||||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
onTap: (e: MouseEvent) => void;
|
onTap: (e: MouseEvent) => void;
|
||||||
onWheel: (e: WheelEvent) => void;
|
onWheel: (e: WheelEvent) => void;
|
||||||
@@ -26,7 +31,8 @@
|
|||||||
const {
|
const {
|
||||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
||||||
|
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const INSPECT_ZOOM_STEP = 0.15;
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
@@ -57,8 +63,38 @@
|
|||||||
let inspectPanStartX = 0;
|
let inspectPanStartX = 0;
|
||||||
let inspectPanStartY = 0;
|
let inspectPanStartY = 0;
|
||||||
|
|
||||||
|
let stripDragging = false;
|
||||||
|
let stripDragMoved = false;
|
||||||
|
let stripDragStartY = 0;
|
||||||
|
let stripScrollStart = 0;
|
||||||
|
|
||||||
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pinchZoomEnabled) {
|
||||||
|
pinch = createPinchTracker({
|
||||||
|
getZoom: onGetZoom,
|
||||||
|
setZoom: onSetZoom,
|
||||||
|
getInspectScale: () => readerState.inspectScale,
|
||||||
|
setInspectScale: (s) => { readerState.inspectScale = s; },
|
||||||
|
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0; },
|
||||||
|
isLongstrip: () => style === "longstrip",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pinch = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function onInspectMouseDown(e: MouseEvent) {
|
export function onInspectMouseDown(e: MouseEvent) {
|
||||||
if (style === "longstrip" || readerState.inspectScale <= 1) return;
|
if (style === "longstrip") {
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (readerState.inspectScale <= 1) return;
|
||||||
inspectDragging = true;
|
inspectDragging = true;
|
||||||
inspectDragMoved = false;
|
inspectDragMoved = false;
|
||||||
inspectDragStartX = e.clientX;
|
inspectDragStartX = e.clientX;
|
||||||
@@ -69,6 +105,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseMove(e: MouseEvent) {
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
|
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);
|
||||||
@@ -79,12 +121,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onInspectMouseUp() {
|
export function onInspectMouseUp() {
|
||||||
|
stripDragging = false;
|
||||||
inspectDragging = false;
|
inspectDragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
pinch?.onPointerDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerMove(e: PointerEvent) {
|
||||||
|
if (pinch?.isPinching()) {
|
||||||
|
pinch.onPointerMove(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stripDragging) {
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
if (inspectDragging) {
|
||||||
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
|
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
|
||||||
|
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
|
||||||
|
readerState.inspectPanX = cx;
|
||||||
|
readerState.inspectPanY = cy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerUp(e: PointerEvent) {
|
||||||
|
pinch?.onPointerUp(e);
|
||||||
|
if (!pinch?.isPinching()) {
|
||||||
|
stripDragging = false;
|
||||||
|
inspectDragging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handleWheel(e: WheelEvent) {
|
export function handleWheel(e: WheelEvent) {
|
||||||
if (e.ctrlKey) { onWheel(e); return; }
|
if (style === "longstrip") {
|
||||||
if (style === "longstrip") return;
|
if (e.ctrlKey) { onWheel(e); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.ctrlKey) { onWheel(e); return; }
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
||||||
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
|
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
|
||||||
@@ -107,6 +185,7 @@
|
|||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
if (style === "longstrip") return;
|
if (style === "longstrip") return;
|
||||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||||
onTap(e);
|
onTap(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +206,9 @@
|
|||||||
onclick={handleTap}
|
onclick={handleTap}
|
||||||
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
|
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
|
||||||
onmousedown={onInspectMouseDown}
|
onmousedown={onInspectMouseDown}
|
||||||
|
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) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -189,12 +270,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
|
||||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
.viewer:focus { outline: none; }
|
.viewer:focus { outline: none; }
|
||||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||||
.viewer.inspect-active:active { cursor: grabbing; }
|
.viewer.inspect-active:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
:global(.pinch-active) .viewer { touch-action: none; }
|
||||||
|
|
||||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
.img { display: block; user-select: none; image-rendering: auto; }
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||||
import { store, updateSettings, openReader, closeReader, addHistory,
|
import { store, updateSettings, openReader, closeReader, addHistory,
|
||||||
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
|
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
|
||||||
setSettingsOpen } from "@store/state.svelte";
|
setSettingsOpen, setMangaReaderSettings, clearMangaReaderSettings,
|
||||||
|
saveReaderPreset, updateReaderPreset, deleteReaderPreset } from "@store/state.svelte";
|
||||||
import { setReading } from "@store/discord";
|
import { setReading } from "@store/discord";
|
||||||
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
|
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
|
||||||
import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
|
import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
|
||||||
@@ -21,20 +22,31 @@
|
|||||||
import PageView from "./PageView.svelte";
|
import PageView from "./PageView.svelte";
|
||||||
import ReaderProgressBar from "./ReaderProgressBar.svelte";
|
import ReaderProgressBar from "./ReaderProgressBar.svelte";
|
||||||
import ReaderOverlay from "./ReaderOverlay.svelte";
|
import ReaderOverlay from "./ReaderOverlay.svelte";
|
||||||
|
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
||||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
|
||||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
const effectiveReaderSettings = $derived.by(() => {
|
||||||
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
const mangaId = store.activeManga?.id;
|
||||||
const zoom = $derived(store.settings.readerZoom ?? 1.0);
|
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||||
|
return override ? { ...store.settings, ...override } : store.settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
|
||||||
|
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as FitMode);
|
||||||
|
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
||||||
|
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
|
||||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||||||
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
|
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
|
||||||
|
const barPosition = $derived((store.settings.barPosition ?? "top") as "top" | "left" | "right");
|
||||||
|
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
const lastPage = $derived(store.pageUrls.length);
|
||||||
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
||||||
const zoomPct = $derived(Math.round(zoom * 100));
|
const zoomPct = $derived(Math.round(zoom * 100));
|
||||||
|
const pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
|
||||||
|
|
||||||
const displayChapter = $derived(
|
const displayChapter = $derived(
|
||||||
style === "longstrip" && readerState.visibleChapterId
|
style === "longstrip" && readerState.visibleChapterId
|
||||||
@@ -84,7 +96,7 @@
|
|||||||
fit === "height" && "fit-height",
|
fit === "height" && "fit-height",
|
||||||
fit === "screen" && "fit-screen",
|
fit === "screen" && "fit-screen",
|
||||||
fit === "original" && "fit-original",
|
fit === "original" && "fit-original",
|
||||||
store.settings.optimizeContrast && "optimize-contrast",
|
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||||
].filter(Boolean).join(" "));
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||||||
@@ -119,6 +131,11 @@
|
|||||||
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||||||
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
||||||
|
|
||||||
|
const perMangaEnabled = $derived(
|
||||||
|
store.activeManga?.id != null &&
|
||||||
|
!!(store.settings.mangaReaderSettings ?? {})[store.activeManga.id]
|
||||||
|
);
|
||||||
|
|
||||||
let containerEl: HTMLDivElement | null = null;
|
let containerEl: HTMLDivElement | null = null;
|
||||||
let pageViewRef: PageView;
|
let pageViewRef: PageView;
|
||||||
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
||||||
@@ -184,7 +201,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
captureZoomAnchor(containerEl, style, zoomAnchor);
|
captureZoomAnchor(containerEl, style, zoomAnchor);
|
||||||
const ZOOM_STEP = 0.05;
|
const ZOOM_STEP = 0.05;
|
||||||
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
|
applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
|
||||||
restoreZoomAnchor(containerEl, zoomAnchor);
|
restoreZoomAnchor(containerEl, zoomAnchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +219,10 @@
|
|||||||
closeReader,
|
closeReader,
|
||||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
||||||
lastPage: () => lastPage,
|
lastPage: () => lastPage,
|
||||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ 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); updateSettings({ 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); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||||
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||||
openSettings: () => setSettingsOpen(true),
|
openSettings: () => setSettingsOpen(true),
|
||||||
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
||||||
toggleMarker: () => {
|
toggleMarker: () => {
|
||||||
@@ -230,6 +247,54 @@
|
|||||||
|
|
||||||
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
||||||
|
|
||||||
|
function captureCurrentReaderSettings() {
|
||||||
|
return {
|
||||||
|
pageStyle: style,
|
||||||
|
fitMode: fit,
|
||||||
|
readingDirection: (store.settings.readingDirection ?? "ltr") as import("@store/state.svelte").ReadingDirection,
|
||||||
|
readerZoom: zoom,
|
||||||
|
pageGap: effectiveReaderSettings.pageGap ?? true,
|
||||||
|
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
|
||||||
|
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
|
||||||
|
} satisfies import("@store/state.svelte").ReaderSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(patch: Parameters<typeof updateSettings>[0]) {
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
setMangaReaderSettings(mangaId, { ...(store.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
|
||||||
|
} else {
|
||||||
|
updateSettings(patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTogglePerManga() {
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
if (mangaId == null) return;
|
||||||
|
if ((store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
clearMangaReaderSettings(mangaId);
|
||||||
|
} else {
|
||||||
|
setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSavePreset(name: string) {
|
||||||
|
saveReaderPreset(name, captureCurrentReaderSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyPreset(settings: import("@store/state.svelte").ReaderSettings) {
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
setMangaReaderSettings(mangaId, settings);
|
||||||
|
} else {
|
||||||
|
updateSettings(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBarPositionChange(pos: "top" | "left" | "right") {
|
||||||
|
updateSettings({ barPosition: pos });
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const chapter = displayChapter;
|
const chapter = displayChapter;
|
||||||
const manga = store.activeManga;
|
const manga = store.activeManga;
|
||||||
@@ -346,7 +411,7 @@
|
|||||||
const snap = store.pageUrls;
|
const snap = store.pageUrls;
|
||||||
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
||||||
if (cancelled || snap !== store.pageUrls) return;
|
if (cancelled || snap !== store.pageUrls) return;
|
||||||
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false);
|
readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
} else { readerState.pageGroups = []; }
|
} else { readerState.pageGroups = []; }
|
||||||
@@ -415,6 +480,8 @@
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||||
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||||
|
window.addEventListener("pointermove", pageViewRef.onPointerMove);
|
||||||
|
window.addEventListener("pointerup", pageViewRef.onPointerUp);
|
||||||
|
|
||||||
readerState.isFullscreen = await win.isFullscreen();
|
readerState.isFullscreen = await win.isFullscreen();
|
||||||
const unlistenFs = await win.onResized(async () => {
|
const unlistenFs = await win.onResized(async () => {
|
||||||
@@ -436,6 +503,8 @@
|
|||||||
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("pointerup", pageViewRef.onPointerUp);
|
||||||
cleanupScroll();
|
cleanupScroll();
|
||||||
unlistenFs();
|
unlistenFs();
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
@@ -446,17 +515,27 @@
|
|||||||
<div
|
<div
|
||||||
class="root"
|
class="root"
|
||||||
class:overlay-bars={overlayBars}
|
class:overlay-bars={overlayBars}
|
||||||
|
class:bar-left={barPosition === "left"}
|
||||||
|
class:bar-right={barPosition === "right"}
|
||||||
|
class:pinch-active={pinchZoomEnabled}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }}
|
onmousemove={(e) => {
|
||||||
|
if (!tapToToggleBar) {
|
||||||
|
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
|
||||||
|
if (barPosition === "left" && e.clientX < 60) showUi();
|
||||||
|
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ReaderControls
|
<ReaderControls
|
||||||
{displayChapter} {adjacent} {visibleChunkLastPage}
|
{displayChapter} {adjacent} {visibleChunkLastPage}
|
||||||
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct}
|
{zoom} {zoomPct}
|
||||||
isFullscreen={readerState.isFullscreen}
|
isFullscreen={readerState.isFullscreen}
|
||||||
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
||||||
{autoNext} {markOnNext}
|
|
||||||
uiVisible={readerState.uiVisible}
|
uiVisible={readerState.uiVisible}
|
||||||
{hideTimer}
|
{hideTimer}
|
||||||
|
{barPosition}
|
||||||
|
progressBar={isVerticalBar ? progressBarSnippet : undefined}
|
||||||
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||||
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||||
onMaybeMarkRead={maybeMarkCurrentRead}
|
onMaybeMarkRead={maybeMarkCurrentRead}
|
||||||
@@ -464,10 +543,31 @@
|
|||||||
onCommitMarker={commitMarker}
|
onCommitMarker={commitMarker}
|
||||||
onDeleteMarker={deleteCurrentMarker}
|
onDeleteMarker={deleteCurrentMarker}
|
||||||
onClampZoom={clampZoom}
|
onClampZoom={clampZoom}
|
||||||
|
onApplySettings={applySettings}
|
||||||
onDlOpen={() => readerState.dlOpen = true}
|
onDlOpen={() => readerState.dlOpen = true}
|
||||||
|
onSettingsOpen={() => setSettingsOpen(true)}
|
||||||
|
{perMangaEnabled}
|
||||||
{win}
|
{win}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if readerState.presetOpen}
|
||||||
|
<ReaderPresetPanel
|
||||||
|
{fit} {style} {rtl} {zoom} {zoomPct}
|
||||||
|
{perMangaEnabled}
|
||||||
|
{barPosition}
|
||||||
|
onBarPositionChange={handleBarPositionChange}
|
||||||
|
onTogglePerManga={handleTogglePerManga}
|
||||||
|
onApplySettings={applySettings}
|
||||||
|
onSavePreset={handleSavePreset}
|
||||||
|
onApplyPreset={handleApplyPreset}
|
||||||
|
onUpdatePreset={updateReaderPreset}
|
||||||
|
onDeletePreset={deleteReaderPreset}
|
||||||
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||||
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||||
|
onClampZoom={clampZoom}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ReaderOverlay
|
<ReaderOverlay
|
||||||
{showResumeBanner}
|
{showResumeBanner}
|
||||||
resumePage={readerState.resumePage}
|
resumePage={readerState.resumePage}
|
||||||
@@ -486,6 +586,9 @@
|
|||||||
{currentGroup} {stripToRender}
|
{currentGroup} {stripToRender}
|
||||||
fadingOut={readerState.fadingOut}
|
fadingOut={readerState.fadingOut}
|
||||||
{tapToToggleBar}
|
{tapToToggleBar}
|
||||||
|
{pinchZoomEnabled}
|
||||||
|
onGetZoom={() => zoom}
|
||||||
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||||
onTap={handleTap}
|
onTap={handleTap}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
@@ -493,21 +596,44 @@
|
|||||||
{bindContainer}
|
{bindContainer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#snippet progressBarSnippet()}
|
||||||
<ReaderProgressBar
|
<ReaderProgressBar
|
||||||
{style}
|
{style}
|
||||||
loading={readerState.loading}
|
loading={readerState.loading}
|
||||||
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||||
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||||
uiVisible={readerState.uiVisible}
|
uiVisible={readerState.uiVisible}
|
||||||
|
{barPosition}
|
||||||
onGoPrev={goPrev}
|
onGoPrev={goPrev}
|
||||||
onGoNext={goNext}
|
onGoNext={goNext}
|
||||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||||
/>
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !isVerticalBar}
|
||||||
|
<ReaderProgressBar
|
||||||
|
{style}
|
||||||
|
loading={readerState.loading}
|
||||||
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||||
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||||
|
uiVisible={readerState.uiVisible}
|
||||||
|
{barPosition}
|
||||||
|
onGoPrev={goPrev}
|
||||||
|
onGoNext={goNext}
|
||||||
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
||||||
|
|
||||||
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||||||
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
||||||
.root.overlay-bars :global(.viewer) { height: 100%; }
|
.root.overlay-bars :global(.viewer) { height: 100%; }
|
||||||
|
|
||||||
|
.root.bar-left :global(.viewer) { margin-left: 40px; }
|
||||||
|
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||||
|
|
||||||
|
.root.pinch-active :global(.viewer) { touch-action: none; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,35 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
X, CaretLeft, CaretRight,
|
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
||||||
Square, Rows, BookOpen, MonitorPlay,
|
|
||||||
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
|
||||||
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||||
Bookmark, MapPin, Download, Check,
|
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import { store, updateSettings } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
import { openReader, closeReader } from "@store/state.svelte";
|
import { openReader, closeReader } from "@store/state.svelte";
|
||||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte";
|
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||||
import type { FitMode } from "@store/state.svelte";
|
import { fly } from "svelte/transition";
|
||||||
|
import { cubicOut, cubicIn } from "svelte/easing";
|
||||||
import type { Chapter } from "@types";
|
import type { Chapter } from "@types";
|
||||||
|
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
displayChapter: Chapter | null;
|
displayChapter: Chapter | null;
|
||||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||||
visibleChunkLastPage: number;
|
visibleChunkLastPage: number;
|
||||||
fit: FitMode;
|
|
||||||
fitLabel: string;
|
|
||||||
style: string;
|
|
||||||
rtl: boolean;
|
|
||||||
zoom: number;
|
zoom: number;
|
||||||
zoomPct: number;
|
zoomPct: number;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isBookmarked: boolean;
|
isBookmarked: boolean;
|
||||||
hasMarkerOnPage: boolean;
|
hasMarkerOnPage: boolean;
|
||||||
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
|
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
|
||||||
autoNext: boolean;
|
|
||||||
markOnNext: boolean;
|
|
||||||
uiVisible: boolean;
|
uiVisible: boolean;
|
||||||
hideTimer: ReturnType<typeof setTimeout> | null;
|
hideTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
progressBar?: Snippet;
|
||||||
onCaptureZoomAnchor: () => void;
|
onCaptureZoomAnchor: () => void;
|
||||||
onRestoreZoomAnchor: () => void;
|
onRestoreZoomAnchor: () => void;
|
||||||
onMaybeMarkRead: () => void;
|
onMaybeMarkRead: () => void;
|
||||||
@@ -37,47 +34,62 @@
|
|||||||
onCommitMarker: () => void;
|
onCommitMarker: () => void;
|
||||||
onDeleteMarker: () => void;
|
onDeleteMarker: () => void;
|
||||||
onClampZoom: (z: number) => number;
|
onClampZoom: (z: number) => number;
|
||||||
|
onApplySettings: (patch: Parameters<typeof updateSettings>[0]) => void;
|
||||||
onDlOpen: () => void;
|
onDlOpen: () => void;
|
||||||
|
onSettingsOpen: () => void;
|
||||||
|
hasMangaOverride: boolean;
|
||||||
win: import("@tauri-apps/api/window").Window;
|
win: import("@tauri-apps/api/window").Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
displayChapter, adjacent, visibleChunkLastPage,
|
displayChapter, adjacent, visibleChunkLastPage,
|
||||||
fit, fitLabel, style, rtl, zoom, zoomPct,
|
zoom, zoomPct, isFullscreen,
|
||||||
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
||||||
autoNext, markOnNext, uiVisible, hideTimer,
|
uiVisible, hideTimer,
|
||||||
|
barPosition, progressBar,
|
||||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||||
onClampZoom, onDlOpen, win,
|
onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
|
||||||
|
hasMangaOverride, win,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
const popoverSide = $derived(
|
||||||
|
barPosition === "left" ? "right" :
|
||||||
|
barPosition === "right" ? "left" :
|
||||||
|
"bottom"
|
||||||
|
);
|
||||||
|
|
||||||
function adjustZoom(delta: number) {
|
function adjustZoom(delta: number) {
|
||||||
onCaptureZoomAnchor();
|
onCaptureZoomAnchor();
|
||||||
updateSettings({ readerZoom: onClampZoom(zoom + delta) });
|
onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
|
||||||
onRestoreZoomAnchor();
|
onRestoreZoomAnchor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom() {
|
function resetZoom() {
|
||||||
onCaptureZoomAnchor();
|
onCaptureZoomAnchor();
|
||||||
updateSettings({ readerZoom: 1.0 });
|
onApplySettings({ readerZoom: 1.0 });
|
||||||
onRestoreZoomAnchor();
|
onRestoreZoomAnchor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleStyle() {
|
|
||||||
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
|
|
||||||
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFit() {
|
|
||||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
|
||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function keepUiAlive() {
|
function keepUiAlive() {
|
||||||
readerState.uiVisible = true;
|
readerState.uiVisible = true;
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function wcResetTimer() {
|
||||||
|
if (wcTimer) clearTimeout(wcTimer);
|
||||||
|
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (readerState.winOpen) wcResetTimer();
|
||||||
|
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
|
||||||
|
return () => { if (wcTimer) clearTimeout(wcTimer); };
|
||||||
|
});
|
||||||
|
|
||||||
function openMarkerPopover() {
|
function openMarkerPopover() {
|
||||||
if (currentPageMarkers.length > 0) {
|
if (currentPageMarkers.length > 0) {
|
||||||
const first = currentPageMarkers[0];
|
const first = currentPageMarkers[0];
|
||||||
@@ -86,105 +98,115 @@
|
|||||||
readerState.openMarker("", "", "yellow");
|
readerState.openMarker("", "", "yellow");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chapterHover = $state(false);
|
||||||
|
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showChapterPopover() {
|
||||||
|
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
|
||||||
|
chapterHover = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideChapterPopover() {
|
||||||
|
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="topbar" class:hidden={!uiVisible}>
|
<div
|
||||||
|
class="bar"
|
||||||
<div class="topbar-left">
|
class:bar-top={barPosition === "top"}
|
||||||
|
class:bar-left={barPosition === "left"}
|
||||||
|
class:bar-right={barPosition === "right"}
|
||||||
|
class:hidden={!uiVisible}
|
||||||
|
>
|
||||||
|
<div class="bar-start">
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
|
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
|
||||||
disabled={!adjacent.prev}>
|
disabled={!adjacent.prev}>
|
||||||
|
{#if isVertical}
|
||||||
|
<CaretUp size={14} weight="light" />
|
||||||
|
{:else}
|
||||||
<CaretLeft size={14} weight="light" />
|
<CaretLeft size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="ch-label">
|
|
||||||
|
<div
|
||||||
|
class="ch-hover-wrap"
|
||||||
|
onmouseenter={showChapterPopover}
|
||||||
|
onmouseleave={hideChapterPopover}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<button class="ch-pill" title="{store.activeManga?.title} / {displayChapter?.name}">
|
||||||
|
{#if isVertical}
|
||||||
|
<span class="ch-info"></span>
|
||||||
|
{:else}
|
||||||
<span class="ch-title">{store.activeManga?.title}</span>
|
<span class="ch-title">{store.activeManga?.title}</span>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-sep">/</span>
|
||||||
<span>{displayChapter?.name}</span>
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
</span>
|
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if chapterHover && isVertical}
|
||||||
|
<div class="ch-popover ch-popover-{popoverSide}">
|
||||||
|
<span class="ch-pop-title">{store.activeManga?.title}</span>
|
||||||
|
<span class="ch-pop-sep">/</span>
|
||||||
|
<span class="ch-pop-name">{displayChapter?.name}</span>
|
||||||
|
<span class="ch-pop-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="icon-btn"
|
<button class="icon-btn"
|
||||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
|
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
|
||||||
disabled={!adjacent.next}>
|
disabled={!adjacent.next}>
|
||||||
|
{#if isVertical}
|
||||||
|
<CaretDown size={14} weight="light" />
|
||||||
|
{:else}
|
||||||
<CaretRight size={14} weight="light" />
|
<CaretRight size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<span class="bar-sep"></span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topbar-right">
|
{#if isVertical && progressBar}
|
||||||
<div class="top-sep"></div>
|
<div class="bar-middle">
|
||||||
|
{@render progressBar()}
|
||||||
<button class="mode-btn" onclick={cycleFit}>
|
</div>
|
||||||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
{/if}
|
||||||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
|
||||||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
|
||||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
|
||||||
<span class="mode-label">{fitLabel}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<div class="bar-end">
|
||||||
<div class="zoom-wrap">
|
<div class="zoom-wrap">
|
||||||
<div class="zoom-inline">
|
<div class="zoom-inline">
|
||||||
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||||
<MagnifyingGlassMinus size={13} weight="light" />
|
<MagnifyingGlassMinus size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
<div class="zoom-divider"></div>
|
||||||
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
|
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
|
||||||
{zoomPct}%
|
{zoomPct}%
|
||||||
</button>
|
</button>
|
||||||
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
<div class="zoom-divider"></div>
|
||||||
|
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||||
<MagnifyingGlassPlus size={13} weight="light" />
|
<MagnifyingGlassPlus size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if readerState.zoomOpen}
|
{#if readerState.zoomOpen}
|
||||||
<div class="zoom-popover">
|
<div class="popover zoom-popover popover-{popoverSide}">
|
||||||
<div class="zoom-slider-row">
|
<div class="zoom-slider-row">
|
||||||
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
||||||
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
||||||
</div>
|
</div>
|
||||||
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
|
||||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
|
|
||||||
{#if style === "single"}<Square size={14} weight="light" />
|
|
||||||
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
|
|
||||||
{:else if style === "double"}<BookOpen size={14} weight="light" />
|
|
||||||
{:else}<Rows size={14} weight="light" />{/if}
|
|
||||||
<span class="mode-label">{style}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mode-extras">
|
|
||||||
{#if style === "double"}
|
|
||||||
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads}
|
|
||||||
onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
|
|
||||||
<span class="mode-label">Offset</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if style === "longstrip"}
|
|
||||||
<button class="mode-btn" class:active={store.settings.pageGap}
|
|
||||||
onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
|
||||||
<span class="mode-label">Gap</span>
|
|
||||||
</button>
|
|
||||||
<button class="mode-btn" class:active={autoNext}
|
|
||||||
onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
|
||||||
<span class="mode-label">Auto</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if !autoNext}
|
|
||||||
<button class="mode-btn" class:active={markOnNext}
|
|
||||||
onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
|
||||||
<span class="mode-label">Mk.Read</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="mode-btn" onclick={onDlOpen}>
|
|
||||||
<Download size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="marker-wrap">
|
<div class="marker-wrap">
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -198,7 +220,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if readerState.markerOpen}
|
{#if readerState.markerOpen}
|
||||||
<div class="marker-popover" role="presentation"
|
<div class="popover marker-popover popover-{popoverSide}" role="presentation"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onmouseenter={keepUiAlive}
|
onmouseenter={keepUiAlive}
|
||||||
>
|
>
|
||||||
@@ -254,6 +276,20 @@
|
|||||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" onclick={onDlOpen}>
|
||||||
|
<Download size={14} weight="light" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" class:active={hasMangaOverride}
|
||||||
|
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
|
||||||
|
title="Reader settings">
|
||||||
|
<Sliders size={13} weight="regular" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
|
||||||
|
<GearSix size={13} weight="regular" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="wc-wrap">
|
<div class="wc-wrap">
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -268,45 +304,98 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if readerState.winOpen}
|
{#if readerState.winOpen}
|
||||||
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}>
|
<div
|
||||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }}>
|
class="wc-clip wc-clip-{popoverSide}"
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
onmouseenter={wcResetTimer}
|
||||||
<span>Minimize</span>
|
onmousemove={wcResetTimer}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="wc-bar"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
in:fly={isVertical
|
||||||
|
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
|
||||||
|
: { y: '-100%', duration: 200, easing: cubicOut }}
|
||||||
|
out:fly={isVertical
|
||||||
|
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
|
||||||
|
: { y: '-100%', duration: 150, easing: cubicIn }}
|
||||||
|
>
|
||||||
|
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize">
|
||||||
|
<svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }}>
|
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}>
|
||||||
{#if isFullscreen}
|
{#if isFullscreen}
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="wc-btn wc-close" onclick={() => { readerState.winOpen = false; win.close(); }}>
|
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="Close">
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Close</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
.bar {
|
||||||
.topbar.hidden { opacity: 0; pointer-events: none; }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
background: var(--bg-void);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.bar.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; }
|
.bar-top {
|
||||||
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
flex-direction: row;
|
||||||
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
height: 40px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-left, .bar-right {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
width: 40px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
||||||
|
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
|
.bar-start, .bar-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
.bar-top .bar-start { flex: 1; overflow: hidden; }
|
||||||
|
.bar-left .bar-start,
|
||||||
|
.bar-left .bar-end,
|
||||||
|
.bar-right .bar-start,
|
||||||
|
.bar-right .bar-end {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
@@ -314,25 +403,104 @@
|
|||||||
.icon-btn.active { color: var(--accent-fg); }
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||||
|
|
||||||
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
.ch-hover-wrap { position: relative; min-width: 0; }
|
||||||
|
|
||||||
|
.ch-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
cursor: default;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.bar-left .ch-pill, .bar-right .ch-pill {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
||||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
.ch-popover {
|
||||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
position: absolute;
|
||||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
background: var(--bg-raised);
|
||||||
.mode-label { text-transform: capitalize; }
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||||
|
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||||
|
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.ch-pop-sep { color: var(--text-faint); }
|
||||||
|
.ch-pop-name { color: var(--text-muted); }
|
||||||
|
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||||
|
|
||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||||
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
.zoom-inline { display: flex; align-items: center; }
|
||||||
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
.bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
|
||||||
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
|
.zoom-icon-btn { width: 28px; height: 28px; }
|
||||||
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
|
.zoom-divider {
|
||||||
|
background: var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bar-top .zoom-divider { width: 1px; height: 16px; }
|
||||||
|
.bar-left .zoom-divider,
|
||||||
|
.bar-right .zoom-divider { height: 1px; width: 16px; }
|
||||||
|
|
||||||
|
.zoom-pct-btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
height: 28px;
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.bar-left .zoom-pct-btn,
|
||||||
|
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
|
||||||
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 100;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
|
||||||
|
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||||
|
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||||
|
|
||||||
|
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
|
||||||
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||||
@@ -341,7 +509,7 @@
|
|||||||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
.marker-wrap { position: relative; flex-shrink: 0; }
|
.marker-wrap { position: relative; flex-shrink: 0; }
|
||||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
.marker-popover { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||||
@@ -362,12 +530,68 @@
|
|||||||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
.wc-wrap { position: relative; flex-shrink: 0; }
|
.wc-wrap { position: static; flex-shrink: 0; }
|
||||||
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
.wc-clip {
|
||||||
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); }
|
position: absolute;
|
||||||
.wc-btn svg { flex-shrink: 0; opacity: 0.75; }
|
z-index: 100;
|
||||||
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
}
|
||||||
.wc-close:hover { color: #fff; background: #c0392b; }
|
.wc-clip-bottom {
|
||||||
|
top: 100%;
|
||||||
|
right: var(--sp-3);
|
||||||
|
clip-path: inset(0 -20px -20px -20px);
|
||||||
|
}
|
||||||
|
.wc-clip-right {
|
||||||
|
left: calc(100% + 1px);
|
||||||
|
top: auto;
|
||||||
|
bottom: var(--sp-3);
|
||||||
|
clip-path: inset(-20px -20px -20px 0);
|
||||||
|
}
|
||||||
|
.wc-clip-left {
|
||||||
|
right: calc(100% + 1px);
|
||||||
|
top: auto;
|
||||||
|
bottom: var(--sp-3);
|
||||||
|
clip-path: inset(-20px 0 -20px -20px);
|
||||||
|
}
|
||||||
|
.wc-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 3px 10px 4px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
|
||||||
|
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
|
||||||
|
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
|
||||||
|
|
||||||
|
.wc-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
.wc-icon-close:hover { color: #fff; background: #c0392b; }
|
||||||
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
|
||||||
|
.bar-middle {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
X, Check, Trash, FloppyDisk,
|
||||||
|
Square, Rows, BookOpen, MonitorPlay,
|
||||||
|
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||||
|
ArrowsHorizontal,
|
||||||
|
SidebarSimple,
|
||||||
|
} from "phosphor-svelte";
|
||||||
|
import type { ReaderSettings, ReaderPreset, FitMode } from "@store/state.svelte";
|
||||||
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
|
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fit: FitMode;
|
||||||
|
style: string;
|
||||||
|
rtl: boolean;
|
||||||
|
zoom: number;
|
||||||
|
zoomPct: number;
|
||||||
|
perMangaEnabled: boolean;
|
||||||
|
onTogglePerManga: () => void;
|
||||||
|
onSavePreset: (name: string) => void;
|
||||||
|
onApplyPreset: (settings: ReaderSettings) => void;
|
||||||
|
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
|
||||||
|
onDeletePreset: (id: string) => void;
|
||||||
|
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||||
|
onCaptureZoomAnchor: () => void;
|
||||||
|
onRestoreZoomAnchor: () => void;
|
||||||
|
onClampZoom: (z: number) => number;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
onBarPositionChange: (pos: "top" | "left" | "right") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
fit, style, rtl, zoom, zoomPct,
|
||||||
|
perMangaEnabled, onTogglePerManga,
|
||||||
|
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
|
||||||
|
onApplySettings,
|
||||||
|
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
|
||||||
|
barPosition, onBarPositionChange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const presets = $derived(store.settings.readerPresets ?? []);
|
||||||
|
const effectiveSettings = $derived.by(() => {
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||||
|
return override ? { ...store.settings, ...override } : store.settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
let presetSaving = $state(false);
|
||||||
|
let presetNameInput = $state("");
|
||||||
|
let presetEditId = $state<string | null>(null);
|
||||||
|
let presetEditName = $state("");
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
readerState.presetOpen = false;
|
||||||
|
presetSaving = false;
|
||||||
|
presetNameInput = "";
|
||||||
|
presetEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitSavePreset() {
|
||||||
|
if (!presetNameInput.trim()) return;
|
||||||
|
onSavePreset(presetNameInput.trim());
|
||||||
|
presetSaving = false;
|
||||||
|
presetNameInput = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRenamePreset() {
|
||||||
|
if (!presetEditId || !presetEditName.trim()) return;
|
||||||
|
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
|
||||||
|
presetEditId = null;
|
||||||
|
presetEditName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSettings(s: ReaderSettings): string {
|
||||||
|
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
|
||||||
|
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
|
||||||
|
if (!s.pageGap) parts.push("no gap");
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(v: number) {
|
||||||
|
onCaptureZoomAnchor();
|
||||||
|
onApplySettings({ readerZoom: onClampZoom(v) });
|
||||||
|
onRestoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
|
||||||
|
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
|
||||||
|
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
|
||||||
|
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
|
||||||
|
{ value: "original", label: "Original", icon: ArrowsOut },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styleOptions: { value: string; label: string; icon: any }[] = [
|
||||||
|
{ value: "single", label: "Single", icon: Square },
|
||||||
|
{ value: "double", label: "Double", icon: BookOpen },
|
||||||
|
{ value: "fade", label: "Fade", icon: MonitorPlay },
|
||||||
|
{ value: "longstrip", label: "Long Strip", icon: Rows },
|
||||||
|
];
|
||||||
|
|
||||||
|
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backdrop" role="presentation" onclick={close} transition:fade={{ duration: 150 }}></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Reader settings & presets"
|
||||||
|
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Reader Settings</span>
|
||||||
|
{#if store.activeManga}
|
||||||
|
<span class="panel-manga">{store.activeManga.title}</span>
|
||||||
|
{/if}
|
||||||
|
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Page Style</p>
|
||||||
|
<div class="option-grid">
|
||||||
|
{#each styleOptions as o}
|
||||||
|
<button
|
||||||
|
class="option-tile"
|
||||||
|
class:active={style === o.value}
|
||||||
|
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
|
||||||
|
>
|
||||||
|
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={style === o.value ? "fill" : "light"} /></div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if style === "double"}
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Offset double spreads</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.offsetDoubleSpreads}
|
||||||
|
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={effectiveSettings.offsetDoubleSpreads}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if style === "longstrip"}
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Gap between pages</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.pageGap ?? true}
|
||||||
|
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={effectiveSettings.pageGap ?? true}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Auto next chapter</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={store.settings.autoNextChapter ?? false}
|
||||||
|
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.autoNextChapter ?? false}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Fit Mode</p>
|
||||||
|
<div class="option-grid">
|
||||||
|
{#each fitOptions as o}
|
||||||
|
<button
|
||||||
|
class="option-tile"
|
||||||
|
class:active={fit === o.value}
|
||||||
|
onclick={() => onApplySettings({ fitMode: o.value })}
|
||||||
|
>
|
||||||
|
<div class="tile-icon"><svelte:component this={o.icon} size={18} weight={fit === o.value ? "fill" : "light"} /></div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Reading Direction</p>
|
||||||
|
<div class="dir-row">
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={!rtl}
|
||||||
|
onclick={() => onApplySettings({ readingDirection: "ltr" })}
|
||||||
|
>
|
||||||
|
<ArrowsHorizontal size={14} weight="light" />
|
||||||
|
<span>Left to Right</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={rtl}
|
||||||
|
onclick={() => onApplySettings({ readingDirection: "rtl" })}
|
||||||
|
>
|
||||||
|
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
|
||||||
|
<span>Right to Left</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Bar Position</p>
|
||||||
|
<div class="bar-grid">
|
||||||
|
{#each barOptions as o}
|
||||||
|
<button
|
||||||
|
class="bar-tile"
|
||||||
|
class:active={barPosition === o.value}
|
||||||
|
onclick={() => onBarPositionChange(o.value)}
|
||||||
|
>
|
||||||
|
<div class="bar-tile-preview bar-preview-{o.value}">
|
||||||
|
<div class="bar-preview-strip"></div>
|
||||||
|
<div class="bar-preview-content"></div>
|
||||||
|
</div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<p class="section-label" style="margin:0">Zoom</p>
|
||||||
|
<span class="zoom-readout">{zoomPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-row">
|
||||||
|
<button class="zoom-step" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}>−</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="zoom-slider"
|
||||||
|
min={Math.round(ZOOM_MIN * 100)}
|
||||||
|
max={Math.round(ZOOM_MAX * 100)}
|
||||||
|
step={5}
|
||||||
|
value={zoomPct}
|
||||||
|
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
|
||||||
|
/>
|
||||||
|
<button class="zoom-step" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Image</p>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Optimize contrast</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.optimizeContrast}
|
||||||
|
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={effectiveSettings.optimizeContrast}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Pinch to zoom <span class="toggle-badge">experimental</span></span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={store.settings.pinchZoom ?? false}
|
||||||
|
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.pinchZoom ?? false}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Mark read on chapter advance</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={store.settings.markReadOnNext ?? true}
|
||||||
|
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.markReadOnNext ?? true}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if store.activeManga}
|
||||||
|
<section class="section">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Per-manga settings</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={perMangaEnabled}
|
||||||
|
onclick={onTogglePerManga}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={perMangaEnabled}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<p class="section-label" style="margin:0">Saved Presets</p>
|
||||||
|
{#if !presetSaving}
|
||||||
|
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if presetSaving}
|
||||||
|
<div class="preset-name-row">
|
||||||
|
<input
|
||||||
|
class="preset-name-input"
|
||||||
|
placeholder="Preset name…"
|
||||||
|
bind:value={presetNameInput}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
|
||||||
|
/>
|
||||||
|
<button class="small-btn" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
|
||||||
|
<button class="small-btn" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if presets.length === 0 && !presetSaving}
|
||||||
|
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="preset-list">
|
||||||
|
{#each presets as p (p.id)}
|
||||||
|
{#if presetEditId === p.id}
|
||||||
|
<div class="preset-name-row">
|
||||||
|
<input
|
||||||
|
class="preset-name-input"
|
||||||
|
bind:value={presetEditName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
|
||||||
|
/>
|
||||||
|
<button class="small-btn" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
|
||||||
|
<button class="small-btn" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="preset-row">
|
||||||
|
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
|
||||||
|
<span class="preset-name">{p.name}</span>
|
||||||
|
<span class="preset-desc">{describeSettings(p.settings)}</span>
|
||||||
|
</button>
|
||||||
|
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
|
||||||
|
<FloppyDisk size={12} weight="regular" />
|
||||||
|
</button>
|
||||||
|
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
|
||||||
|
<Trash size={12} weight="regular" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: calc(var(--z-reader) + 20);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 320px;
|
||||||
|
z-index: calc(var(--z-reader) + 21);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-left: 1px solid var(--border-base);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0 var(--sp-4);
|
||||||
|
height: 48px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-manga {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-dim) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-2) var(--sp-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.tile-icon { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
|
||||||
|
|
||||||
|
.bar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-2) var(--sp-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.bar-tile-preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.bar-tile.active .bar-tile-preview { opacity: 1; }
|
||||||
|
|
||||||
|
.bar-preview-strip {
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bar-preview-content {
|
||||||
|
flex: 1;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-preview-top { flex-direction: column; }
|
||||||
|
.bar-preview-left { flex-direction: row; }
|
||||||
|
.bar-preview-right { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
|
||||||
|
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
|
||||||
|
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-badge {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-left: var(--sp-1);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
.toggle.on { background: var(--accent-fg); }
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: left var(--t-base);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toggle.on .toggle-knob { left: 16px; }
|
||||||
|
|
||||||
|
.dir-row { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.dir-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.zoom-readout {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.zoom-step:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
|
||||||
|
.zoom-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zoom-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-preset-btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px var(--sp-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.new-preset-btn:hover { background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.preset-name-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.preset-name-input:focus { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.preset-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.preset-apply {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 7px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.preset-apply:hover { background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-desc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
.small-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
activeChapterMarkers: MarkerEntry[];
|
activeChapterMarkers: MarkerEntry[];
|
||||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||||
uiVisible: boolean;
|
uiVisible: boolean;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
onGoPrev: () => void;
|
onGoPrev: () => void;
|
||||||
onGoNext: () => void;
|
onGoNext: () => void;
|
||||||
onJumpToPage: (page: number) => void;
|
onJumpToPage: (page: number) => void;
|
||||||
@@ -25,11 +26,15 @@
|
|||||||
const {
|
const {
|
||||||
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
|
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
|
||||||
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
|
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
|
||||||
|
barPosition,
|
||||||
onGoPrev, onGoNext, onJumpToPage,
|
onGoPrev, onGoNext, onJumpToPage,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
{#if !isVertical}
|
||||||
|
<div class="bottombar" class:hidden={!uiVisible}>
|
||||||
<button class="nav-btn" onclick={onGoPrev}
|
<button class="nav-btn" onclick={onGoPrev}
|
||||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
@@ -66,14 +71,14 @@
|
|||||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||||
|
|
||||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||||
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||||
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
|
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each activeChapterMarkers as m (m.id)}
|
{#each activeChapterMarkers as m (m.id)}
|
||||||
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
|
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
|
||||||
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
|
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -89,7 +94,58 @@
|
|||||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
||||||
<ArrowRight size={13} weight="light" />
|
<ArrowRight size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
|
||||||
|
{#if sliderMax > 1}
|
||||||
|
<div
|
||||||
|
class="vslider-wrap"
|
||||||
|
class:dragging={readerState.sliderDragging}
|
||||||
|
role="slider"
|
||||||
|
aria-valuenow={sliderPage}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={sliderMax}
|
||||||
|
tabindex="-1"
|
||||||
|
onmouseenter={() => readerState.sliderHover = true}
|
||||||
|
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
readerState.sliderDragging = true;
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||||
|
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||||
|
}}
|
||||||
|
onmousemove={(e) => {
|
||||||
|
if (!readerState.sliderDragging) return;
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||||
|
onJumpToPage(Math.round(1 + ratio * (sliderMax - 1)));
|
||||||
|
}}
|
||||||
|
onmouseup={() => readerState.sliderDragging = false}
|
||||||
|
>
|
||||||
|
<div class="vslider-track-bg">
|
||||||
|
<div class="vslider-fill" style="height:{sliderPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="vslider-thumb" style="top:{sliderPct}%"></div>
|
||||||
|
|
||||||
|
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||||
|
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
|
<div class="vslider-checkpoint bookmark-checkpoint" style="top:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each activeChapterMarkers as m (m.id)}
|
||||||
|
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
|
<div class="vslider-checkpoint marker-checkpoint" style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||||
|
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
|
||||||
|
{sliderPage} / {sliderMax}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||||||
@@ -109,4 +165,91 @@
|
|||||||
.marker-checkpoint { opacity: 0.85; }
|
.marker-checkpoint { opacity: 0.85; }
|
||||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||||
|
|
||||||
|
.vbar-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vbar-progress.hidden { opacity: 0; }
|
||||||
|
|
||||||
|
.vslider-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
margin: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
.vslider-track-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
left: 50%;
|
||||||
|
translate: -50% 0;
|
||||||
|
}
|
||||||
|
.vslider-fill {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: height 0.05s linear;
|
||||||
|
}
|
||||||
|
.vslider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
|
||||||
|
transition: transform var(--t-fast);
|
||||||
|
}
|
||||||
|
.vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||||
|
.vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; }
|
||||||
|
.vslider-checkpoint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.vslider-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 6px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
.vslider-tooltip.tooltip-right {
|
||||||
|
left: auto;
|
||||||
|
right: calc(100% + 6px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { store, addHistory, addBookmark, removeBookmark,
|
|||||||
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
|
||||||
const AVG_MIN_PER_PAGE = 0.33;
|
const AVG_MIN_PER_PAGE = 0.33;
|
||||||
|
|
||||||
@@ -30,7 +31,9 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
|||||||
if (!mangaId) return;
|
if (!mangaId) return;
|
||||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||||
checkAndMarkCompleted(mangaId, updated);
|
checkAndMarkCompleted(mangaId, updated);
|
||||||
|
const ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||||||
const prefs = getMangaPrefs();
|
const prefs = getMangaPrefs();
|
||||||
|
if (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs);
|
||||||
if (prefs.deleteOnRead) {
|
if (prefs.deleteOnRead) {
|
||||||
const ch = store.activeChapterList.find(c => c.id === id);
|
const ch = store.activeChapterList.find(c => c.id === id);
|
||||||
if (ch?.isDownloaded) {
|
if (ch?.isDownloaded) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store, openReader } from "@store/state.svelte";
|
import { store, openReader } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
@@ -23,6 +24,9 @@ export async function loadChapter(
|
|||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
store.pageUrls = [];
|
store.pageUrls = [];
|
||||||
|
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
if (mangaId) trackingState.loadForManga(mangaId);
|
||||||
|
|
||||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
const bookmark = store.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;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { clampZoom } from "./zoomHelpers";
|
||||||
|
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||||
|
|
||||||
|
export interface PinchTrackerOptions {
|
||||||
|
getZoom: () => number;
|
||||||
|
setZoom: (z: number) => void;
|
||||||
|
getInspectScale: () => number;
|
||||||
|
setInspectScale: (s: number) => void;
|
||||||
|
resetInspectPan: () => void;
|
||||||
|
isLongstrip: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchTracker {
|
||||||
|
onPointerDown: (e: PointerEvent) => void;
|
||||||
|
onPointerMove: (e: PointerEvent) => void;
|
||||||
|
onPointerUp: (e: PointerEvent) => void;
|
||||||
|
isPinching: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
|
export function createPinchTracker(opts: PinchTrackerOptions): PinchTracker {
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let startDist = 0;
|
||||||
|
let startZoom = 0;
|
||||||
|
let startInspect = 0;
|
||||||
|
let pinching = false;
|
||||||
|
|
||||||
|
function dist(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
||||||
|
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
startDist = dist(a, b);
|
||||||
|
startZoom = opts.getZoom();
|
||||||
|
startInspect = opts.getInspectScale();
|
||||||
|
pinching = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!pinching || !pointers.has(e.pointerId)) return;
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.size < 2) return;
|
||||||
|
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
const current = dist(a, b);
|
||||||
|
if (startDist === 0) return;
|
||||||
|
const ratio = current / startDist;
|
||||||
|
|
||||||
|
if (opts.isLongstrip()) {
|
||||||
|
opts.setZoom(clampZoom(startZoom * ratio));
|
||||||
|
} else {
|
||||||
|
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * ratio));
|
||||||
|
if (next !== opts.getInspectScale()) {
|
||||||
|
if (next === 1) opts.resetInspectPan();
|
||||||
|
opts.setInspectScale(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
if (pointers.size < 2) {
|
||||||
|
pinching = false;
|
||||||
|
startDist = 0;
|
||||||
|
startZoom = 0;
|
||||||
|
startInspect = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPinching() { return pinching; }
|
||||||
|
|
||||||
|
return { onPointerDown, onPointerMove, onPointerUp, isPinching };
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class ReaderState {
|
|||||||
dlOpen = $state(false);
|
dlOpen = $state(false);
|
||||||
zoomOpen = $state(false);
|
zoomOpen = $state(false);
|
||||||
winOpen = $state(false);
|
winOpen = $state(false);
|
||||||
|
presetOpen = $state(false);
|
||||||
|
presetNameInput = $state("");
|
||||||
nextN = $state(5);
|
nextN = $state(5);
|
||||||
dlBusy = $state(false);
|
dlBusy = $state(false);
|
||||||
|
|
||||||
@@ -84,6 +86,7 @@ class ReaderState {
|
|||||||
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||||
if (this.dlOpen) { this.dlOpen = false; return true; }
|
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||||
if (this.winOpen) { this.winOpen = false; return true; }
|
if (this.winOpen) { this.winOpen = false; return true; }
|
||||||
|
if (this.presetOpen) { this.presetOpen = false; return true; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-header-right">
|
<div class="list-header-right">
|
||||||
<!-- Jump to chapter -->
|
|
||||||
<div class="jump-wrap">
|
<div class="jump-wrap">
|
||||||
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
|
||||||
<MagnifyingGlass size={14} weight="light" />
|
<MagnifyingGlass size={14} weight="light" />
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scanlator filter -->
|
|
||||||
{#if availableScanlators.length > 1}
|
{#if availableScanlators.length > 1}
|
||||||
<div class="scan-filter-wrap">
|
<div class="scan-filter-wrap">
|
||||||
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
|
||||||
@@ -245,12 +245,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Refresh -->
|
|
||||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
|
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Folder picker -->
|
|
||||||
<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}>
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
|
||||||
{#if chapters.length > 0}
|
{#if chapters.length > 0}
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||||
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Top pagination -->
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>←</button>
|
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>←</button>
|
||||||
@@ -355,7 +355,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ─── Header bar ──────────────────────────────────────────── */
|
|
||||||
.list-header {
|
.list-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
|
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
|
||||||
@@ -364,7 +363,6 @@
|
|||||||
.list-header-left,
|
.list-header-left,
|
||||||
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
/* ─── Sort ────────────────────────────────────────────────── */
|
|
||||||
.sort-btn {
|
.sort-btn {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
@@ -390,7 +388,6 @@
|
|||||||
.sort-option.active { color: var(--accent-fg); }
|
.sort-option.active { color: var(--accent-fg); }
|
||||||
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||||
|
|
||||||
/* ─── Icon buttons ────────────────────────────────────────── */
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
@@ -402,7 +399,6 @@
|
|||||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
/* ─── Jump ────────────────────────────────────────────────── */
|
|
||||||
.jump-wrap { position: relative; }
|
.jump-wrap { position: relative; }
|
||||||
.jump-popover {
|
.jump-popover {
|
||||||
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
|
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
|
||||||
@@ -429,7 +425,6 @@
|
|||||||
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||||||
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
/* ─── Folder picker ───────────────────────────────────────── */
|
|
||||||
.fp-wrap { position: relative; }
|
.fp-wrap { position: relative; }
|
||||||
.fp-menu {
|
.fp-menu {
|
||||||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
|
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
|
||||||
@@ -476,7 +471,6 @@
|
|||||||
}
|
}
|
||||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
/* ─── Download dropdown ───────────────────────────────────── */
|
|
||||||
.dl-wrap { position: relative; }
|
.dl-wrap { position: relative; }
|
||||||
.dl-dropdown {
|
.dl-dropdown {
|
||||||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
|
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
|
||||||
@@ -545,7 +539,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 (top) ────────────────────────────────────── */
|
|
||||||
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
|
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.page-btn {
|
.page-btn {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
@@ -557,7 +550,6 @@
|
|||||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
.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); }
|
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
/* ─── Selection toolbar ───────────────────────────────────── */
|
|
||||||
.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);
|
||||||
@@ -572,7 +564,6 @@
|
|||||||
.sel-action-danger { color: var(--color-error) !important; }
|
.sel-action-danger { color: var(--color-error) !important; }
|
||||||
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||||||
|
|
||||||
/* ─── Scanlator filter ────────────────────────────────────── */
|
|
||||||
.scan-filter-wrap { position: relative; }
|
.scan-filter-wrap { position: relative; }
|
||||||
.scan-filter-panel {
|
.scan-filter-panel {
|
||||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
|
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
|
||||||
@@ -637,6 +628,5 @@
|
|||||||
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
|
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
|
||||||
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
|
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
|
||||||
|
|
||||||
/* ─── Shared animation (used by dropdowns/popovers) ───────── */
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
</style>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||||
clearMarkersForManga,
|
clearMarkersForManga,
|
||||||
} from "@store/state.svelte";
|
} from "@store/state.svelte";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import type { Manga, Chapter, Category } from "@types";
|
import type { Manga, Chapter, Category } from "@types";
|
||||||
@@ -80,18 +81,21 @@
|
|||||||
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
|
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
|
||||||
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
|
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, {
|
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
|
||||||
sortMode, sortDir,
|
|
||||||
preferredScanlator: get("preferredScanlator") as string,
|
|
||||||
scanlatorFilter: scanlatorFilter as string[],
|
|
||||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
|
||||||
scanlatorForce: scanlatorForce as boolean,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||||
@@ -107,19 +111,16 @@
|
|||||||
const bookmark = store.activeManga
|
const bookmark = store.activeManga
|
||||||
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
||||||
: null;
|
: null;
|
||||||
if (bookmark) {
|
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
|
||||||
const ch = asc.find(c => c.id === bookmark.chapterId);
|
if (bookmarkedCh && !bookmarkedCh.isRead) {
|
||||||
if (ch) {
|
return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
|
||||||
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
|
||||||
const allRead = asc.every(c => c.isRead);
|
|
||||||
if (!(isLastChapter && allRead))
|
|
||||||
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
const firstUnread = asc.find(c => !c.isRead);
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
const target = inProgress ?? firstUnread;
|
||||||
|
if (target) {
|
||||||
|
return { chapter: target, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||||
|
}
|
||||||
return { chapter: asc[0], type: "reread" as const, resumePage: null };
|
return { chapter: asc[0], type: "reread" as const, resumePage: null };
|
||||||
})());
|
})());
|
||||||
|
|
||||||
@@ -160,7 +161,8 @@
|
|||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
function applyChapters(nodes: Chapter[]) {
|
||||||
if (get("autoDownload") && _prevChapterIds.size > 0) {
|
if (get("autoDownload") && _prevChapterIds.size > 0) {
|
||||||
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
const filtered = buildChapterList(nodes, currentPrefs);
|
||||||
|
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
|
||||||
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
|
||||||
}
|
}
|
||||||
_prevChapterIds = new Set(nodes.map(c => c.id));
|
_prevChapterIds = new Set(nodes.map(c => c.id));
|
||||||
@@ -249,9 +251,33 @@
|
|||||||
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
|
||||||
|
if (!store.settings.trackerSyncBack) return;
|
||||||
|
const records = trackingState.recordsFor(mangaId);
|
||||||
|
if (!records.length) return;
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs);
|
||||||
|
if (markedIds.length > 0) {
|
||||||
|
const idSet = new Set(markedIds);
|
||||||
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead: true } : c);
|
||||||
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const m = store.activeManga;
|
const m = store.activeManga;
|
||||||
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
if (m) untrack(() => {
|
||||||
|
acknowledgeUpdate(m.id);
|
||||||
|
loadManga(m.id);
|
||||||
|
loadChapters(m.id);
|
||||||
|
loadCategories(m.id);
|
||||||
|
trackingState.loadForManga(m.id).then(() => {
|
||||||
|
syncTrackersIntoChapters(m.id, chapters);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
@@ -298,9 +324,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (mangaId) {
|
||||||
|
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
checkAndMarkCompleted(mangaId, chapters);
|
||||||
|
const ch = chapters.find(c => c.id === chapterId);
|
||||||
|
if (ch) {
|
||||||
|
if (isRead) {
|
||||||
|
await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs);
|
||||||
|
} else {
|
||||||
|
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
if (get("deleteOnRead")) {
|
if (get("deleteOnRead")) {
|
||||||
const ch = chapters.find(c => c.id === chapterId);
|
const ch = chapters.find(c => c.id === chapterId);
|
||||||
@@ -323,24 +361,37 @@
|
|||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (mangaId) {
|
||||||
if (isRead && get("deleteOnRead")) {
|
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
|
||||||
|
checkAndMarkCompleted(mangaId, chapters);
|
||||||
|
if (isRead) {
|
||||||
|
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1);
|
||||||
|
if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs);
|
||||||
|
} else {
|
||||||
|
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRead) {
|
||||||
|
if (get("deleteOnRead")) {
|
||||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
if (toDelete.length) {
|
if (toDelete.length) {
|
||||||
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
||||||
const doDelete = async () => {
|
const doDelete = async () => {
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||||
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
if (mangaId) chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
|
||||||
};
|
};
|
||||||
if (delayMs === 0) doDelete();
|
if (delayMs === 0) doDelete();
|
||||||
else setTimeout(doDelete, delayMs);
|
else setTimeout(doDelete, delayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
|
import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking";
|
||||||
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
|
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
|
||||||
import { addToast } from "@store/state.svelte";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import { addToast, store } from "@store/state.svelte";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
|
||||||
import type { Tracker, TrackRecord, TrackSearch } from "@types";
|
import type { Tracker, TrackRecord, TrackSearch } from "@types";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
|
||||||
let { mangaId, mangaTitle, onClose }: {
|
let { mangaId, mangaTitle, onClose }: {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -16,8 +20,7 @@
|
|||||||
type TabId = "records" | number;
|
type TabId = "records" | number;
|
||||||
|
|
||||||
let trackers: Tracker[] = $state([]);
|
let trackers: Tracker[] = $state([]);
|
||||||
let records: TrackRecord[] = $state([]);
|
let loadingTrackers: boolean = $state(true);
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let activeTab: TabId = $state("records");
|
let activeTab: TabId = $state("records");
|
||||||
|
|
||||||
let searchQuery: string = $state("");
|
let searchQuery: string = $state("");
|
||||||
@@ -30,26 +33,22 @@
|
|||||||
let syncing: number | null = $state(null);
|
let syncing: number | null = $state(null);
|
||||||
let editingChapter: number | null = $state(null);
|
let editingChapter: number | null = $state(null);
|
||||||
let chapterDraft: number = $state(0);
|
let chapterDraft: number = $state(0);
|
||||||
|
let applyingRecord: number | null = $state(null);
|
||||||
|
|
||||||
|
const records = $derived(trackingState.records);
|
||||||
|
const loading = $derived(trackingState.loading || loadingTrackers);
|
||||||
|
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
||||||
|
|
||||||
async function load() {
|
$effect(() => {
|
||||||
loading = true;
|
loadingTrackers = true;
|
||||||
try {
|
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||||
const [tRes, rRes] = await Promise.all([
|
.then(r => { trackers = r.trackers.nodes; })
|
||||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
.catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message }))
|
||||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
|
.finally(() => { loadingTrackers = false; });
|
||||||
]);
|
trackingState.loadForManga(mangaId);
|
||||||
trackers = tRes.trackers.nodes;
|
});
|
||||||
records = rRes.manga.trackRecords.nodes;
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const tab = activeTab;
|
const tab = activeTab;
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
|
|
||||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||||
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
|
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
|
||||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@@ -96,7 +94,7 @@
|
|||||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||||
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
||||||
);
|
);
|
||||||
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
trackingState.patchRecord(res.bindTrack.trackRecord);
|
||||||
activeTab = "records";
|
activeTab = "records";
|
||||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -110,7 +108,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||||
records = records.filter(r => r.id !== record.id);
|
trackingState.removeRecord(record.id);
|
||||||
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
||||||
@@ -119,15 +117,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
|
||||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStatus(record: TrackRecord, status: number) {
|
async function updateStatus(record: TrackRecord, status: number) {
|
||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +133,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,7 +145,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,9 +156,8 @@
|
|||||||
async function syncRecord(record: TrackRecord) {
|
async function syncRecord(record: TrackRecord) {
|
||||||
syncing = record.id;
|
syncing = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
|
const fresh = await trackingState.syncRecordFromRemote(record.id);
|
||||||
patchRecord(res.fetchTrack.trackRecord);
|
if (fresh) addToast({ kind: "success", title: "Synced from tracker" });
|
||||||
addToast({ kind: "success", title: "Synced from tracker" });
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,6 +172,33 @@
|
|||||||
|
|
||||||
function cancelChapterEditor() { editingChapter = null; }
|
function cancelChapterEditor() { editingChapter = null; }
|
||||||
|
|
||||||
|
async function applyToLibrary(record: TrackRecord) {
|
||||||
|
applyingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
|
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
const marked = await syncBackFromTracker(
|
||||||
|
[record],
|
||||||
|
chapRes.chapters.nodes,
|
||||||
|
{
|
||||||
|
threshold: store.settings.trackerSyncBackThreshold ?? null,
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
if (marked.length > 0) {
|
||||||
|
addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
|
||||||
|
} else {
|
||||||
|
addToast({ kind: "info", title: "Already up to date" });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Apply failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
applyingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitChapter(record: TrackRecord) {
|
async function submitChapter(record: TrackRecord) {
|
||||||
const val = Math.max(0, chapterDraft);
|
const val = Math.max(0, chapterDraft);
|
||||||
editingChapter = null;
|
editingChapter = null;
|
||||||
@@ -186,7 +206,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,6 +289,11 @@
|
|||||||
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
||||||
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
{#if store.settings.trackerSyncBack}
|
||||||
|
<button class="record-icon-btn" title="Apply tracker progress to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
|
||||||
|
<ArrowLineDown size={11} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
||||||
<X size={11} weight="bold" />
|
<X size={11} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -226,7 +226,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
}
|
}
|
||||||
.s-toggle.on { background: var(--accent); border-color: var(--accent); }
|
.s-toggle.on,
|
||||||
|
.s-toggle-on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
.s-toggle-thumb {
|
.s-toggle-thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -238,12 +239,46 @@
|
|||||||
background: var(--text-faint);
|
background: var(--text-faint);
|
||||||
transition: transform var(--t-base), background var(--t-base);
|
transition: transform var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.s-toggle.on .s-toggle-thumb {
|
.s-toggle.on .s-toggle-thumb,
|
||||||
|
.s-toggle-on .s-toggle-thumb {
|
||||||
transform: translateX(15px);
|
transform: translateX(15px);
|
||||||
background: var(--bg-void);
|
background: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── System theme sync pair ───────────────────────────────────────── */
|
||||||
|
.s-sync-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-sync-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 8px var(--sp-4);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-sync-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-sync-item .s-select-btn {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Stepper ──────────────────────────────────────────────────────── */
|
/* ── Stepper ──────────────────────────────────────────────────────── */
|
||||||
.s-stepper {
|
.s-stepper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -382,6 +417,42 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.s-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
transition: transform var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
.s-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.s-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.s-slider::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.s-slider-val {
|
.s-slider-val {
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
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) close(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) { e.stopPropagation(); close(); } };
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey, true);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
|
|
||||||
<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") close(); }}>
|
onkeydown={(e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } }}>
|
||||||
<div class="s-modal" role="dialog" aria-label="Settings">
|
<div class="s-modal" role="dialog" aria-label="Settings">
|
||||||
|
|
||||||
<div class="s-sidebar">
|
<div class="s-sidebar">
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
{#if tab === "general"}
|
{#if tab === "general"}
|
||||||
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||||
{:else if tab === "appearance"}
|
{:else if tab === "appearance"}
|
||||||
<AppearanceSettings {onOpenThemeEditor} />
|
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
|
||||||
{:else if tab === "reader"}
|
{:else if tab === "reader"}
|
||||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||||
{:else if tab === "library"}
|
{:else if tab === "library"}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||||
|
import { autoBackupAppData } from "@core/backup";
|
||||||
|
|
||||||
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
|
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
|
||||||
type UpdatePhase = "idle" | "downloading" | "ready" | "error";
|
type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
|
||||||
const IS_WINDOWS = navigator.userAgent.includes("Windows");
|
const IS_WINDOWS = navigator.userAgent.includes("Windows");
|
||||||
|
|
||||||
let appVersion = $state("…");
|
let appVersion = $state("…");
|
||||||
@@ -33,6 +34,13 @@
|
|||||||
return () => unlisten?.();
|
return () => unlisten?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
listen("update-launching", () => { updatePhase = "launching"; })
|
||||||
|
.then(fn => { unlisten = fn; });
|
||||||
|
return () => unlisten?.();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
releasesLoading = true; releasesError = null;
|
releasesLoading = true; releasesError = null;
|
||||||
try {
|
try {
|
||||||
@@ -80,8 +88,9 @@
|
|||||||
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
|
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
|
||||||
try {
|
try {
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
|
await autoBackupAppData();
|
||||||
try { await invoke("kill_server"); } catch {}
|
try { await invoke("kill_server"); } catch {}
|
||||||
await invoke("download_and_install_update");
|
await invoke("download_and_install_update", { tag: release.tag_name });
|
||||||
updatePhase = "ready";
|
updatePhase = "ready";
|
||||||
} else {
|
} else {
|
||||||
await openUrl(release.html_url);
|
await openUrl(release.html_url);
|
||||||
@@ -134,6 +143,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if updatePhase === "launching"}
|
||||||
|
<div class="s-update-ready">
|
||||||
|
<span class="s-update-ready-label">Launching installer for {targetTag}…</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if updatePhase === "ready"}
|
{#if updatePhase === "ready"}
|
||||||
<div class="s-update-ready">
|
<div class="s-update-ready">
|
||||||
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
|
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
|
||||||
@@ -207,7 +221,7 @@
|
|||||||
<p class="s-section-title">Links</p>
|
<p class="s-section-title">Links</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
|
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
|
||||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
<a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||||
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
|
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Pencil, Trash, Plus } from "phosphor-svelte";
|
import { Pencil, Trash, Plus } from "phosphor-svelte";
|
||||||
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
|
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
|
||||||
|
import { mountSystemThemeSync } from "@core/theme";
|
||||||
|
import { selectPortal } from "@core/actions/selectPortal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
selectOpen: string | null;
|
||||||
|
closingSelect: string | null;
|
||||||
|
toggleSelect: (id: string) => void;
|
||||||
|
anims: boolean;
|
||||||
onOpenThemeEditor?: (id?: string | null) => void;
|
onOpenThemeEditor?: (id?: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onOpenThemeEditor }: Props = $props();
|
let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props();
|
||||||
|
|
||||||
const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [
|
const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [
|
||||||
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
|
{ id: "original", label: "Original", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
|
||||||
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
|
{ id: "dark", label: "Dark", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
|
||||||
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
|
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
|
||||||
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
|
|
||||||
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
|
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
|
||||||
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const allThemeOptions = $derived([
|
||||||
|
...THEMES.map(t => ({ id: t.id, label: t.label })),
|
||||||
|
...(store.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toggleSync() {
|
||||||
|
updateSettings({ systemThemeSync: !store.settings.systemThemeSync });
|
||||||
|
mountSystemThemeSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
let triggerDark: HTMLButtonElement;
|
||||||
|
let triggerLight: HTMLButtonElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Match system theme</span>
|
||||||
|
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={store.settings.systemThemeSync}
|
||||||
|
onclick={toggleSync}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.systemThemeSync}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.settings.systemThemeSync}
|
||||||
|
<div class="s-sync-pair">
|
||||||
|
<div class="s-sync-item">
|
||||||
|
<span class="s-sync-label">Dark theme</span>
|
||||||
|
<div class="s-select">
|
||||||
|
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect("sync-dark")}>
|
||||||
|
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeDark ?? "dark"))?.label ?? "Original"}</span>
|
||||||
|
<svg class="s-select-caret" class:open={selectOpen === "sync-dark"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if selectOpen === "sync-dark" || closingSelect === "sync-dark"}
|
||||||
|
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-dark"} {@attach selectPortal(triggerDark)}>
|
||||||
|
{#each allThemeOptions as opt}
|
||||||
|
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeDark ?? "dark")}
|
||||||
|
onclick={() => { updateSettings({ systemThemeDark: opt.id }); mountSystemThemeSync(); toggleSelect("sync-dark"); }}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-sync-item">
|
||||||
|
<span class="s-sync-label">Light theme</span>
|
||||||
|
<div class="s-select">
|
||||||
|
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect("sync-light")}>
|
||||||
|
<span>{allThemeOptions.find(o => o.id === (store.settings.systemThemeLight ?? "light"))?.label ?? "Light"}</span>
|
||||||
|
<svg class="s-select-caret" class:open={selectOpen === "sync-light"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if selectOpen === "sync-light" || closingSelect === "sync-light"}
|
||||||
|
<div class="s-select-menu" class:anims class:closing={closingSelect === "sync-light"} {@attach selectPortal(triggerLight)}>
|
||||||
|
{#each allThemeOptions as opt}
|
||||||
|
<button class="s-select-option" class:active={opt.id === (store.settings.systemThemeLight ?? "light")}
|
||||||
|
onclick={() => { updateSettings({ systemThemeLight: opt.id }); mountSystemThemeSync(); toggleSelect("sync-light"); }}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Theme</p>
|
<p class="s-section-title">Theme</p>
|
||||||
<div class="s-theme-grid">
|
<div class="s-theme-grid">
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Display</p>
|
<p class="s-section-title">Display</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
|
||||||
|
<button role="switch" aria-checked={store.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={store.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(store.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
<label class="s-row">
|
<label class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
|
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
|
||||||
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
|
||||||
@@ -27,6 +31,12 @@
|
|||||||
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
|
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
|
||||||
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
|
{#if store.settings.libraryShowAllInSaved ?? true}
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
|
||||||
|
<button role="switch" aria-checked={store.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={store.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(store.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||||
|
import { exportAppData, importAppData } from "@core/backup";
|
||||||
|
|
||||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
||||||
let advStorageOpen = $state(false);
|
let advStorageOpen = $state(false);
|
||||||
let backupSectionOpen = $state(false);
|
let backupSectionOpen = $state(false);
|
||||||
|
let appDataSectionOpen = $state(false);
|
||||||
|
|
||||||
async function fetchStorage() {
|
async function fetchStorage() {
|
||||||
storageLoading = true; storageError = null;
|
storageLoading = true; storageError = null;
|
||||||
@@ -324,6 +326,39 @@
|
|||||||
finally { validateLoading = false; }
|
finally { validateLoading = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let appDataExporting = $state(false);
|
||||||
|
let appDataImporting = $state(false);
|
||||||
|
let appDataError = $state<string | null>(null);
|
||||||
|
let appDataMsg = $state<string | null>(null);
|
||||||
|
let appDataBackupDir = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
invoke<string>("get_auto_backup_dir").then(d => { appDataBackupDir = d; }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleExportAppData() {
|
||||||
|
appDataExporting = true; appDataError = null; appDataMsg = null;
|
||||||
|
try {
|
||||||
|
await exportAppData();
|
||||||
|
appDataMsg = "Backup saved.";
|
||||||
|
setTimeout(() => appDataMsg = null, 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e).includes("Cancelled")) return;
|
||||||
|
appDataError = e?.message ?? String(e);
|
||||||
|
} finally { appDataExporting = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportAppData() {
|
||||||
|
appDataImporting = true; appDataError = null; appDataMsg = null;
|
||||||
|
try {
|
||||||
|
await importAppData();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e).includes("Cancelled")) { appDataImporting = false; return; }
|
||||||
|
appDataError = e?.message ?? String(e);
|
||||||
|
appDataImporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
|
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
|
||||||
$effect(() => { return () => stopRestorePoll(); });
|
$effect(() => { return () => stopRestorePoll(); });
|
||||||
</script>
|
</script>
|
||||||
@@ -512,7 +547,6 @@
|
|||||||
{#if !isExternalServer}
|
{#if !isExternalServer}
|
||||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -638,4 +672,56 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
|
||||||
|
<span class="s-label">App-Data Backup</span>
|
||||||
|
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if appDataSectionOpen}
|
||||||
|
<div class="s-collapsible-body">
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Export settings</span>
|
||||||
|
<span class="s-desc">Save all Moku app settings to a JSON file via a native save dialog.</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||||
|
{appDataExporting ? "Saving…" : "Export"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Import settings</span>
|
||||||
|
<span class="s-desc">Restore from a previously exported JSON file. Reloads the app immediately.</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||||
|
{appDataImporting ? "Importing…" : "Import"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if appDataError}
|
||||||
|
<div class="s-banner s-banner-error">{appDataError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if appDataMsg}
|
||||||
|
<div class="s-row">
|
||||||
|
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if appDataBackupDir}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Auto-backup location</span>
|
||||||
|
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn" onclick={() => invoke("open_path", { path: appDataBackupDir })}>Open folder</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
import { GET_TRACKERS } from "@api/queries/tracking";
|
import { GET_TRACKERS } from "@api/queries/tracking";
|
||||||
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
||||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||||
import type { Tracker } from "../../lib/types";
|
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||||
|
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
|
||||||
|
import { GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
|
||||||
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
|
||||||
let trackers = $state<Tracker[]>([]);
|
let trackers = $state<Tracker[]>([]);
|
||||||
let trackersLoading = $state(false);
|
let trackersLoading = $state(false);
|
||||||
@@ -78,6 +83,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||||
|
|
||||||
|
let syncing = $state(false);
|
||||||
|
|
||||||
|
async function runSyncAll() {
|
||||||
|
syncing = true;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ trackers: { nodes: any[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||||
|
const allTrackers = res.trackers.nodes.filter((t: any) => t.isLoggedIn);
|
||||||
|
let totalMarked = 0;
|
||||||
|
|
||||||
|
for (const tracker of allTrackers) {
|
||||||
|
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
||||||
|
if (!record.manga?.id) continue;
|
||||||
|
const mangaId = record.manga.id;
|
||||||
|
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
|
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
|
||||||
|
const marked = await syncBackFromTracker(
|
||||||
|
[record],
|
||||||
|
chapRes.chapters.nodes,
|
||||||
|
{
|
||||||
|
threshold: store.settings.trackerSyncBackThreshold ?? null,
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
totalMarked += marked.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast({ kind: "success", title: "Sync complete", body: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
|
} finally { syncing = false; }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
@@ -148,4 +189,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Sync back from tracker</p>
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Enable sync back</span>
|
||||||
|
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-toggle" class:on={store.settings.trackerSyncBack}
|
||||||
|
onclick={() => updateSettings({ trackerSyncBack: !store.settings.trackerSyncBack })}
|
||||||
|
role="switch" aria-checked={store.settings.trackerSyncBack}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.settings.trackerSyncBack}
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Chapter number tolerance</span>
|
||||||
|
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
|
||||||
|
</div>
|
||||||
|
<button role="switch" aria-checked={store.settings.trackerSyncBackThreshold !== null} class="s-toggle" class:on={store.settings.trackerSyncBackThreshold !== null}
|
||||||
|
onclick={() => updateSettings({ trackerSyncBackThreshold: store.settings.trackerSyncBackThreshold !== null ? null : 20 })}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{#if store.settings.trackerSyncBackThreshold !== null}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (1–20)</span></div>
|
||||||
|
<div class="s-stepper">
|
||||||
|
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (store.settings.trackerSyncBackThreshold ?? 20) - 1) })}>−</button>
|
||||||
|
<span class="s-step-val">{store.settings.trackerSyncBackThreshold}</span>
|
||||||
|
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (store.settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Respect scanlator filter</span>
|
||||||
|
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-toggle" class:on={store.settings.trackerRespectScanlatorFilter}
|
||||||
|
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !store.settings.trackerRespectScanlatorFilter })}
|
||||||
|
role="switch" aria-checked={store.settings.trackerRespectScanlatorFilter}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Sync now</span>
|
||||||
|
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
|
||||||
|
{syncing ? "Syncing…" : "Sync all"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,193 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte";
|
import { CircleNotch } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
|
|
||||||
import { GET_ALL_TRACKER_RECORDS } from "@api/queries";
|
|
||||||
import { UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import type { TrackRecord } from "@types/index";
|
|
||||||
import {
|
import {
|
||||||
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
|
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
|
||||||
scoreToStars, calcProgress, patchTracker, removeRecord,
|
type FlatRecord, type SortKey,
|
||||||
type TrackerWithRecords, type FlatRecord, type SortKey,
|
|
||||||
} from "../lib/trackingSync";
|
} from "../lib/trackingSync";
|
||||||
|
import TrackingToolbar from "./TrackingToolbar.svelte";
|
||||||
let trackers = $state<TrackerWithRecords[]>([]);
|
import TrackingCard from "./TrackingCard.svelte";
|
||||||
let loading = $state(true);
|
import TrackingPreview from "./TrackingPreview.svelte";
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
let activeTrackerId = $state<number | "all">("all");
|
let activeTrackerId = $state<number | "all">("all");
|
||||||
let statusFilter = $state<number | "all">("all");
|
let statusFilter = $state<number | "all">("all");
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let sortBy = $state<SortKey>("title");
|
let sortBy = $state<SortKey>("title");
|
||||||
|
let selectedRecord = $state<FlatRecord | null>(null);
|
||||||
|
|
||||||
let updatingId = $state<number | null>(null);
|
$effect(() => {
|
||||||
let syncingId = $state<number | null>(null);
|
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
|
||||||
let editingChapter = $state<number | null>(null);
|
trackingState.loadAll();
|
||||||
let chapterDraft = $state(0);
|
|
||||||
let confirmUnbind = $state<FlatRecord | null>(null);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true; error = null;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
|
||||||
trackers = res.trackers.nodes;
|
|
||||||
} catch (e: any) {
|
|
||||||
error = e?.message ?? "Failed to load tracking data";
|
|
||||||
} finally { loading = false; }
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => { load(); });
|
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
|
||||||
|
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
|
||||||
const loggedIn = $derived(trackers.filter((t) => t.isLoggedIn));
|
|
||||||
const allRecords = $derived(flattenRecords(trackers));
|
|
||||||
const totalCount = $derived(allRecords.length);
|
const totalCount = $derived(allRecords.length);
|
||||||
|
|
||||||
const statusOptions = $derived(
|
const statusOptions = $derived(
|
||||||
activeTrackerId === "all"
|
activeTrackerId === "all"
|
||||||
? dedupeStatuses(trackers)
|
? dedupeStatuses(trackingState.allTrackers)
|
||||||
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
|
: loggedIn.find(t => t.id === activeTrackerId)?.statuses ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
const filtered = $derived(
|
const filtered = $derived(
|
||||||
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
|
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
|
||||||
);
|
);
|
||||||
|
|
||||||
async function updateStatus(record: FlatRecord, status: number) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
|
|
||||||
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally { updatingId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
|
|
||||||
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally { updatingId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncRecord(record: FlatRecord) {
|
|
||||||
syncingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
|
|
||||||
trackers = patchTracker(trackers, record.trackerId, res.fetchTrack.trackRecord);
|
|
||||||
addToast({ kind: "success", title: "Synced from tracker" });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
|
||||||
} finally { syncingId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unbind(record: FlatRecord) {
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
|
||||||
trackers = removeRecord(trackers, record.trackerId, record.id);
|
|
||||||
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
|
||||||
} finally { updatingId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitChapter(record: FlatRecord) {
|
|
||||||
const val = Math.max(0, chapterDraft);
|
|
||||||
editingChapter = null;
|
|
||||||
if (val === record.lastChapterRead) return;
|
|
||||||
updatingId = record.id;
|
|
||||||
try {
|
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
|
|
||||||
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
|
||||||
} finally { updatingId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openManga(record: FlatRecord) {
|
|
||||||
if (!record.manga) return;
|
|
||||||
setActiveManga(record.manga as any);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openChapterEditor(record: FlatRecord) {
|
|
||||||
editingChapter = record.id;
|
|
||||||
chapterDraft = record.lastChapterRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<TrackingToolbar
|
||||||
<div class="header">
|
{loggedIn}
|
||||||
<div class="header-top">
|
{totalCount}
|
||||||
<h1 class="heading">Tracking</h1>
|
{activeTrackerId}
|
||||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh">
|
{statusFilter}
|
||||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
{statusOptions}
|
||||||
</button>
|
{searchQuery}
|
||||||
</div>
|
{sortBy}
|
||||||
|
loading={trackingState.loadingAll}
|
||||||
{#if !loading && loggedIn.length > 0}
|
onRefresh={() => trackingState.loadAll()}
|
||||||
<div class="tracker-tabs">
|
onTrackerChange={(id) => { activeTrackerId = id; statusFilter = "all"; }}
|
||||||
<button
|
onStatusChange={(v) => statusFilter = v}
|
||||||
class="tracker-tab" class:active={activeTrackerId === "all"}
|
onSearchChange={(v) => searchQuery = v}
|
||||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
onSortChange={(v) => sortBy = v}
|
||||||
>
|
/>
|
||||||
All
|
|
||||||
<span class="tab-pill">{totalCount}</span>
|
|
||||||
</button>
|
|
||||||
{#each loggedIn as t}
|
|
||||||
<button
|
|
||||||
class="tracker-tab" class:active={activeTrackerId === t.id}
|
|
||||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
|
||||||
>
|
|
||||||
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
|
||||||
{t.name}
|
|
||||||
<span class="tab-pill">{t.trackRecords.nodes.length}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
|
||||||
<input class="filter-input" placeholder="Search…" bind:value={searchQuery} />
|
|
||||||
</div>
|
|
||||||
<select class="filter-select" bind:value={statusFilter}
|
|
||||||
onchange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
statusFilter = v === "all" ? "all" : parseInt(v);
|
|
||||||
}}>
|
|
||||||
<option value="all">All statuses</option>
|
|
||||||
{#each statusOptions as s}
|
|
||||||
<option value={s.value}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select class="filter-select" bind:value={sortBy}>
|
|
||||||
<option value="title">Title</option>
|
|
||||||
<option value="status">Status</option>
|
|
||||||
<option value="score">Score</option>
|
|
||||||
<option value="progress">Progress</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
{#if loading}
|
{#if trackingState.loadingAll}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if error}
|
{:else if trackingState.error}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<span class="state-error">{error}</span>
|
<span class="state-error">{trackingState.error}</span>
|
||||||
<button class="ghost-btn" onclick={load}>Retry</button>
|
<button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if loggedIn.length === 0}
|
{:else if loggedIn.length === 0}
|
||||||
@@ -207,240 +81,28 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||||
{@const isBusy = updatingId === record.id}
|
<TrackingCard
|
||||||
{@const isSyncing = syncingId === record.id}
|
{record}
|
||||||
{@const progress = calcProgress(record.lastChapterRead, record.totalChapters)}
|
active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
|
||||||
{@const stars = scoreToStars(record.displayScore, record.tracker.scores)}
|
onSelect={(r) => selectedRecord = r}
|
||||||
|
|
||||||
<div class="card" class:busy={isBusy}>
|
|
||||||
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<div class="cover-click"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
>
|
|
||||||
{#if record.manga?.thumbnailUrl}
|
|
||||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover-img" />
|
|
||||||
{:else}
|
|
||||||
<div class="cover-empty"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cover-actions">
|
|
||||||
{#if record.private}
|
|
||||||
<span class="cover-btn" title="Private"><Lock size={10} weight="fill" /></span>
|
|
||||||
{/if}
|
|
||||||
{#if isSyncing}
|
|
||||||
<span class="cover-btn"><CircleNotch size={10} weight="light" class="anim-spin" /></span>
|
|
||||||
{:else}
|
|
||||||
<button class="cover-btn" title="Sync" onclick={() => syncRecord(record)}>
|
|
||||||
<ArrowsClockwise size={10} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if record.remoteUrl}
|
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="cover-btn" title="Open on {record.tracker.name}">
|
|
||||||
<ArrowSquareOut size={10} weight="light" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button class="cover-btn destroy" title="Unlink" onclick={() => confirmUnbind = record} disabled={isBusy}>
|
|
||||||
<X size={10} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tracker-badge">
|
|
||||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="stars">
|
|
||||||
{#each Array(5) as _, i}
|
|
||||||
<span class="star" class:lit={i < stars}>★</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="title-block"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openManga(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
|
||||||
>
|
|
||||||
<span class="title">{record.title}</span>
|
|
||||||
{#if record.manga?.title && record.manga.title !== record.title}
|
|
||||||
<span class="local-title">{record.manga.title}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls-row">
|
|
||||||
<select class="status-select"
|
|
||||||
value={record.status} disabled={isBusy}
|
|
||||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
|
|
||||||
{#each (record.tracker.statuses ?? []) as s}
|
|
||||||
<option value={s.value}>{s.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select class="score-select"
|
|
||||||
value={record.displayScore} disabled={isBusy}
|
|
||||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
|
|
||||||
{#each (record.tracker.scores ?? []) as s}
|
|
||||||
<option value={s}>★ {s}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if editingChapter === record.id}
|
|
||||||
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="chapter-editor-top">
|
|
||||||
<span class="chapter-label">Chapter</span>
|
|
||||||
<div class="chapter-input-row">
|
|
||||||
<input
|
|
||||||
type="number" class="chapter-input"
|
|
||||||
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
|
||||||
step="0.5" bind:value={chapterDraft}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") submitChapter(record);
|
|
||||||
if (e.key === "Escape") editingChapter = null;
|
|
||||||
}}
|
|
||||||
use:focusEl
|
|
||||||
/>
|
/>
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if record.totalChapters > 0}
|
|
||||||
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
|
||||||
{/if}
|
|
||||||
<div class="chapter-actions">
|
|
||||||
<button class="chapter-cancel" onclick={() => editingChapter = null}>Cancel</button>
|
|
||||||
<button class="chapter-save" onclick={() => submitChapter(record)}>Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="progress-block"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
onclick={() => openChapterEditor(record)}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
|
||||||
>
|
|
||||||
<div class="progress-labels">
|
|
||||||
<span class="progress-text">
|
|
||||||
{#if progress !== null}
|
|
||||||
Ch. {record.lastChapterRead} / {record.totalChapters}
|
|
||||||
{:else if record.lastChapterRead > 0}
|
|
||||||
Ch. {record.lastChapterRead} read
|
|
||||||
{:else}
|
|
||||||
Set chapter…
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if progress !== null}
|
|
||||||
<span class="progress-pct">{Math.round(progress)}%</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="progress-track">
|
|
||||||
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if confirmUnbind}
|
{#if selectedRecord}
|
||||||
{@const r = confirmUnbind}
|
<TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
|
||||||
<div class="modal-backdrop" role="presentation" onclick={() => confirmUnbind = null}>
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
|
||||||
<div class="modal-icon"><X size={16} weight="bold" /></div>
|
|
||||||
<p class="modal-title">Unlink from {r.tracker.name}?</p>
|
|
||||||
<p class="modal-body">
|
|
||||||
<strong>{r.title}</strong> will be removed from your list. Your progress on {r.tracker.name} is unaffected.
|
|
||||||
</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="modal-cancel" onclick={() => confirmUnbind = null}>Cancel</button>
|
|
||||||
<button class="modal-confirm" onclick={async () => { const rec = r; confirmUnbind = null; await unbind(rec); }}>Unlink</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.16s ease both; }
|
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
.body {
|
||||||
.header-top {
|
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||||
padding: var(--sp-4) var(--sp-6) var(--sp-3);
|
|
||||||
}
|
}
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.icon-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); background: none;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.tracker-tabs {
|
|
||||||
display: flex; align-items: center; gap: 1px;
|
|
||||||
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.tracker-tab {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 9px 10px 8px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-faint); background: none; border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.tracker-tab:hover { color: var(--text-muted); }
|
|
||||||
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
|
||||||
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
|
||||||
.tab-pill {
|
|
||||||
font-size: 10px; padding: 0 5px; border-radius: var(--radius-full);
|
|
||||||
background: var(--bg-overlay); color: var(--text-faint);
|
|
||||||
min-width: 18px; text-align: center; line-height: 17px;
|
|
||||||
}
|
|
||||||
.tracker-tab.active .tab-pill { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-5);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.search-wrap {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 4px 10px;
|
|
||||||
}
|
|
||||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
.filter-input {
|
|
||||||
flex: 1; background: none; border: none; outline: none;
|
|
||||||
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
|
|
||||||
}
|
|
||||||
.filter-input::placeholder { color: var(--text-faint); }
|
|
||||||
.filter-select {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 22px 4px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
|
||||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 6px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
|
||||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
@@ -449,6 +111,7 @@
|
|||||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; }
|
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; }
|
||||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
.ghost-btn {
|
.ghost-btn {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
padding: 5px 14px; border-radius: var(--radius-md);
|
padding: 5px 14px; border-radius: var(--radius-md);
|
||||||
@@ -462,206 +125,4 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
|
||||||
gap: var(--sp-4); align-content: start;
|
gap: var(--sp-4); align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color var(--t-base), transform var(--t-base), opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.card:hover { border-color: var(--border-strong); transform: translateY(-1px); }
|
|
||||||
.card.busy { opacity: 0.35; pointer-events: none; }
|
|
||||||
|
|
||||||
.cover-wrap { position: relative; aspect-ratio: 2/3; flex-shrink: 0; overflow: hidden; background: var(--bg-overlay); }
|
|
||||||
.cover-click { position: absolute; inset: 0; cursor: pointer; }
|
|
||||||
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease, opacity 0.2s ease; }
|
|
||||||
.cover-wrap:hover :global(.cover-img) { transform: scale(1.04); opacity: 0.85; }
|
|
||||||
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
.cover-actions {
|
|
||||||
position: absolute; top: 6px; right: 6px; z-index: 2;
|
|
||||||
display: flex; gap: 2px; opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.cover-wrap:hover .cover-actions { opacity: 1; }
|
|
||||||
.cover-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 22px; height: 22px; border-radius: var(--radius-sm);
|
|
||||||
background: rgba(0,0,0,0.55); backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
color: rgba(255,255,255,0.7); cursor: pointer; text-decoration: none;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.cover-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
|
||||||
.cover-btn.destroy:hover { background: rgba(180,40,40,0.65); }
|
|
||||||
.cover-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.tracker-badge {
|
|
||||||
position: absolute; bottom: 8px; right: 8px; z-index: 2;
|
|
||||||
width: 20px; height: 20px; border-radius: 5px;
|
|
||||||
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.5); overflow: hidden;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
|
|
||||||
|
|
||||||
.card-body { display: flex; flex-direction: column; gap: 9px; padding: 11px 12px 12px; }
|
|
||||||
|
|
||||||
.stars { display: flex; gap: 2px; align-items: center; }
|
|
||||||
.star { font-size: 13px; line-height: 1; color: var(--border-strong); transition: color var(--t-base); }
|
|
||||||
.star.lit { color: #f5c518; }
|
|
||||||
|
|
||||||
.title-block {
|
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
|
||||||
cursor: pointer; min-width: 0;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); line-height: 1.38;
|
|
||||||
display: -webkit-box; -webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical; overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.title-block:hover .title { color: var(--accent-fg); }
|
|
||||||
.local-title {
|
|
||||||
font-family: var(--font-ui); font-size: 10px; color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-row { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.status-select {
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 18px 4px 8px; border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
|
||||||
color: var(--text-muted); outline: none; cursor: pointer; appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 6px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.status-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.status-select:disabled { opacity: 0.35; cursor: default; }
|
|
||||||
.status-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.score-select {
|
|
||||||
flex-shrink: 0; width: 54px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 14px 4px 5px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
|
||||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat; background-position: right 4px center;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.score-select:disabled { opacity: 0.35; cursor: default; }
|
|
||||||
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.progress-block {
|
|
||||||
display: flex; flex-direction: column; gap: 6px;
|
|
||||||
padding: 4px 5px; margin: 0 -5px;
|
|
||||||
cursor: pointer; border-radius: var(--radius-sm);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.progress-block:hover { background: var(--bg-overlay); }
|
|
||||||
.progress-labels { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.progress-text { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
|
||||||
|
|
||||||
.chapter-editor {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
|
||||||
.chapter-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-1); }
|
|
||||||
.chapter-input {
|
|
||||||
width: 52px; background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
|
||||||
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-primary); outline: none; text-align: center;
|
|
||||||
appearance: none; -moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
.chapter-input:focus { border-color: var(--accent); }
|
|
||||||
.chapter-input::-webkit-outer-spin-button,
|
|
||||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
||||||
.chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
|
||||||
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
|
||||||
.chapter-save {
|
|
||||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.chapter-save:hover { filter: brightness(1.15); }
|
|
||||||
.chapter-cancel {
|
|
||||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 6px; border-radius: var(--radius-sm);
|
|
||||||
border: none; background: none; color: var(--text-faint);
|
|
||||||
cursor: pointer; transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.chapter-cancel:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed; inset: 0; z-index: 200;
|
|
||||||
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
animation: fadeIn 0.12s ease both;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-xl); padding: var(--sp-6);
|
|
||||||
width: 300px; max-width: calc(100vw - 32px);
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
|
||||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
|
||||||
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
|
||||||
}
|
|
||||||
.modal-icon {
|
|
||||||
width: 36px; height: 36px; border-radius: 50%;
|
|
||||||
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
|
|
||||||
color: var(--color-error); display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary); text-align: center; margin: 0;
|
|
||||||
}
|
|
||||||
.modal-body {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0;
|
|
||||||
}
|
|
||||||
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.modal-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 8px 0; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim); background: none;
|
|
||||||
color: var(--text-muted); cursor: pointer;
|
|
||||||
transition: border-color var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 8px 0; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
|
|
||||||
color: var(--color-error); cursor: pointer;
|
|
||||||
transition: filter var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.modal-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
|
|
||||||
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.94) translateY(6px); }
|
|
||||||
to { opacity: 1; transform: none; }
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: none; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { FlatRecord } from "../lib/trackingSync";
|
||||||
|
import { calcProgress } from "../lib/trackingSync";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: FlatRecord;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: (r: FlatRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { record, active, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="card" class:active onclick={() => onSelect(record)}>
|
||||||
|
<div class="cover-wrap">
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="tracker-badge">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
|
||||||
|
</div>
|
||||||
|
{#if progress !== null}
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width:{progress}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="title">{record.title}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: none; border: none; padding: 0;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover .cover-wrap { border-color: var(--border-strong); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||||
|
.card:hover .title { color: var(--text-primary); }
|
||||||
|
.card.active .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent-dim); }
|
||||||
|
.card.active .title { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.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);
|
||||||
|
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
|
||||||
|
}
|
||||||
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.tracker-badge {
|
||||||
|
position: absolute; bottom: 6px; left: 6px; z-index: 2;
|
||||||
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.4); overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 2px; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { X, ArrowSquareOut, ArrowsClockwise, Lock, CircleNotch, Books } from "phosphor-svelte";
|
||||||
|
import { gql } from "@api/client";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
import { calcProgress, type FlatRecord } from "../lib/trackingSync";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: FlatRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { record, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let updatingId = $state<number | null>(null);
|
||||||
|
let syncingId = $state<number | null>(null);
|
||||||
|
let editingChapter = $state(false);
|
||||||
|
let chapterDraft = $state(record.lastChapterRead);
|
||||||
|
let scoreDraft = $state(record.displayScore ?? "");
|
||||||
|
let confirmUnbind = $state(false);
|
||||||
|
|
||||||
|
const isBusy = $derived(updatingId === record.id);
|
||||||
|
const isSyncing = $derived(syncingId === record.id);
|
||||||
|
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters));
|
||||||
|
const statusName = $derived(record.tracker.statuses?.find(s => s.value === record.status)?.name);
|
||||||
|
|
||||||
|
function prefsForManga(mangaId: number) {
|
||||||
|
return store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(status: number) {
|
||||||
|
const mangaId = record.manga?.id ?? null;
|
||||||
|
if (mangaId === null) return;
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
await trackingState.updateStatus(mangaId, record, status);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally { updatingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScore() {
|
||||||
|
const val = String(scoreDraft).trim();
|
||||||
|
if (val === String(record.displayScore ?? "")) return;
|
||||||
|
const mangaId = record.manga?.id ?? null;
|
||||||
|
if (mangaId === null) return;
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
await trackingState.updateScore(mangaId, record, val);
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally { updatingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChapter() {
|
||||||
|
const val = Math.max(0, chapterDraft);
|
||||||
|
editingChapter = false;
|
||||||
|
if (val === record.lastChapterRead) return;
|
||||||
|
const mangaId = record.manga?.id ?? null;
|
||||||
|
if (mangaId === null) return;
|
||||||
|
updatingId = record.id;
|
||||||
|
try {
|
||||||
|
await trackingState.updateChapterProgress(mangaId, record, val);
|
||||||
|
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||||
|
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: record.manga.id });
|
||||||
|
await trackingState.syncFromRemote(mangaId, { ...record, lastChapterRead: val }, chapRes.chapters.nodes, prefsForManga(mangaId));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
|
} finally { updatingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRecord() {
|
||||||
|
const mangaId = record.manga?.id ?? null;
|
||||||
|
if (mangaId === null) return;
|
||||||
|
syncingId = record.id;
|
||||||
|
try {
|
||||||
|
let chapters: Chapter[] = [];
|
||||||
|
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||||
|
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: record.manga.id });
|
||||||
|
chapters = res.chapters.nodes;
|
||||||
|
}
|
||||||
|
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chapters, prefsForManga(mangaId));
|
||||||
|
const body = markedIds.length > 0 ? `${markedIds.length} chapter${markedIds.length !== 1 ? "s" : ""} marked read` : undefined;
|
||||||
|
addToast({ kind: "success", title: "Synced from tracker", body });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
|
} finally { syncingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind() {
|
||||||
|
const mangaId = record.manga?.id ?? null;
|
||||||
|
if (mangaId === null) return;
|
||||||
|
updatingId = record.id;
|
||||||
|
confirmUnbind = false;
|
||||||
|
try {
|
||||||
|
await trackingState.unbind(mangaId, record);
|
||||||
|
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||||
|
} finally { updatingId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManga() {
|
||||||
|
if (!record.manga) return;
|
||||||
|
setActiveManga(record.manga as any);
|
||||||
|
setNavPage("library");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
onMount(() => window.addEventListener("keydown", onKey));
|
||||||
|
onDestroy(() => window.removeEventListener("keydown", onKey));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-label="Tracking detail">
|
||||||
|
|
||||||
|
<div class="cover-col">
|
||||||
|
<div class="cover-wrap">
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<div class="cover-glow" style="background-image:url({record.manga.thumbnailUrl})"></div>
|
||||||
|
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="tracker-badge">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-badge-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-actions">
|
||||||
|
{#if isSyncing}
|
||||||
|
<div class="action-btn action-btn-inert">
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
|
<span class="action-label">Syncing…</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="action-btn" onclick={syncRecord} disabled={isBusy}>
|
||||||
|
<ArrowsClockwise size={13} weight="light" />
|
||||||
|
<span class="action-label">Sync from tracker</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if record.manga}
|
||||||
|
<button class="action-btn" onclick={openManga}>
|
||||||
|
<Books size={13} weight="light" />
|
||||||
|
<span class="action-label">Go to series</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if record.remoteUrl}
|
||||||
|
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="action-btn">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-icon" />
|
||||||
|
<span class="action-label">Open on {record.tracker.name}</span>
|
||||||
|
<ArrowSquareOut size={11} weight="light" style="flex-shrink:0;opacity:0.5" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="action-btn action-danger" onclick={() => confirmUnbind = true} disabled={isBusy}>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
<span class="action-label">Unlink</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="content-header">
|
||||||
|
<div class="title-block">
|
||||||
|
<h2 class="title">{record.title}</h2>
|
||||||
|
{#if record.manga?.title && record.manga.title !== record.title}
|
||||||
|
<p class="byline">{record.manga.title}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge badge-tracker">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-icon" />
|
||||||
|
{record.tracker.name}
|
||||||
|
</span>
|
||||||
|
{#if statusName}
|
||||||
|
<span class="badge badge-accent">{statusName}</span>
|
||||||
|
{/if}
|
||||||
|
{#if record.private}
|
||||||
|
<span class="badge badge-private"><Lock size={10} weight="fill" /> Private</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-box">
|
||||||
|
<div class="progress-box-top">
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{record.lastChapterRead > 0 ? record.lastChapterRead : "—"}</span>
|
||||||
|
<span class="progress-stat-label">read</span>
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<div class="progress-divider"></div>
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{record.totalChapters}</span>
|
||||||
|
<span class="progress-stat-label">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-divider"></div>
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{Math.max(0, record.totalChapters - record.lastChapterRead)}</span>
|
||||||
|
<span class="progress-stat-label">left</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !editingChapter}
|
||||||
|
<button class="edit-btn" onclick={() => { editingChapter = true; chapterDraft = record.lastChapterRead; }} disabled={isBusy}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if progress !== null}
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width:{progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-pct">{Math.round(progress)}% complete</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingChapter}
|
||||||
|
<div class="chapter-editor">
|
||||||
|
<div class="chapter-input-row">
|
||||||
|
<input
|
||||||
|
type="number" class="chapter-input"
|
||||||
|
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||||
|
step="0.5"
|
||||||
|
bind:value={chapterDraft}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") submitChapter(); if (e.key === "Escape") editingChapter = false; }}
|
||||||
|
use:focusEl
|
||||||
|
/>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||||
|
{/if}
|
||||||
|
<div class="chapter-actions">
|
||||||
|
<button class="chapter-cancel" onclick={() => editingChapter = false}>Cancel</button>
|
||||||
|
<button class="chapter-save" onclick={submitChapter}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="control-group">
|
||||||
|
<span class="control-label">Status</span>
|
||||||
|
<select
|
||||||
|
class="field-select"
|
||||||
|
value={record.status}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateStatus(parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
{#each (record.tracker.statuses ?? []) as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<span class="control-label">Score</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="field-input"
|
||||||
|
bind:value={scoreDraft}
|
||||||
|
disabled={isBusy}
|
||||||
|
min={record.tracker.scores?.[0] ?? 0}
|
||||||
|
max={record.tracker.scores?.[record.tracker.scores.length - 1] ?? 10}
|
||||||
|
step="0.1"
|
||||||
|
onblur={submitScore}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-section">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Tracker</span>
|
||||||
|
<span class="meta-val">{record.tracker.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if record.manga?.title}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Local title</span>
|
||||||
|
<span class="meta-val">{record.manga.title}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if record.startDate}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Started</span>
|
||||||
|
<span class="meta-val">{record.startDate}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if record.finishDate}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Finished</span>
|
||||||
|
<span class="meta-val">{record.finishDate}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmUnbind}
|
||||||
|
<div class="confirm-backdrop" role="presentation" onclick={() => confirmUnbind = false}>
|
||||||
|
<div class="confirm-modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="confirm-icon"><X size={16} weight="bold" /></div>
|
||||||
|
<p class="confirm-title">Unlink from {record.tracker.name}?</p>
|
||||||
|
<p class="confirm-body"><strong>{record.title}</strong> will be removed from your list. Your progress on {record.tracker.name} is unaffected.</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="confirm-cancel" onclick={() => confirmUnbind = false}>Cancel</button>
|
||||||
|
<button class="confirm-confirm" onclick={unbind}>Unlink</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: var(--z-settings);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(720px, calc(100vw - 48px));
|
||||||
|
height: min(520px, calc(100vh - 80px));
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
|
animation: scaleIn 0.16s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-col {
|
||||||
|
width: 190px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
gap: var(--sp-3); overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-wrap { position: relative; width: 100%; }
|
||||||
|
.cover-glow {
|
||||||
|
position: absolute; inset: -20px; z-index: 0;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
filter: blur(24px) saturate(1.4);
|
||||||
|
opacity: 0.18;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
:global(.cover) {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; aspect-ratio: 2/3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.cover-empty {
|
||||||
|
width: 100%; aspect-ratio: 2/3;
|
||||||
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
.tracker-badge {
|
||||||
|
position: absolute; bottom: 7px; right: 7px; z-index: 2;
|
||||||
|
width: 22px; height: 22px; border-radius: 5px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
:global(.tracker-badge-img) { width: 16px; height: 16px; object-fit: contain; display: block; }
|
||||||
|
|
||||||
|
.col-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.action-btn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; text-align: left; text-decoration: none;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.action-btn-inert { cursor: default; pointer-events: none; }
|
||||||
|
.action-btn:hover:not(:disabled):not(.action-btn-inert) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.action-danger:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||||
|
}
|
||||||
|
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
:global(.tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.content-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); margin: 0; }
|
||||||
|
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); margin: 0; }
|
||||||
|
.close-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm); color: var(--text-faint);
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto;
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.content-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.badge-tracker { background: var(--bg-overlay); border-color: var(--border-dim); color: var(--text-muted); }
|
||||||
|
.badge-private { background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.25); color: #f59e0b; }
|
||||||
|
:global(.badge-icon) { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||||
|
|
||||||
|
.progress-box {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4); background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.progress-box-top { display: flex; align-items: center; gap: var(--sp-4); }
|
||||||
|
.progress-stat { display: flex; flex-direction: column; align-items: center; gap: 1px; }
|
||||||
|
.progress-stat-value { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: 1; }
|
||||||
|
.progress-stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); }
|
||||||
|
.progress-divider { width: 1px; height: 24px; background: var(--border-dim); }
|
||||||
|
.edit-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 4px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.edit-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
.edit-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||||
|
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); }
|
||||||
|
|
||||||
|
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-1); border-top: 1px solid var(--border-dim); }
|
||||||
|
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.chapter-input {
|
||||||
|
width: 70px; background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 8px; font-family: var(--font-ui); font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary); outline: none; text-align: center;
|
||||||
|
appearance: none; -moz-appearance: textfield;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-input:focus { border-color: var(--accent); }
|
||||||
|
.chapter-input::-webkit-outer-spin-button,
|
||||||
|
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||||
|
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
|
.chapter-save {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 16px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-save:hover { filter: brightness(1.15); }
|
||||||
|
.chapter-cancel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: none; background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-cancel:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.controls-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
|
||||||
|
.control-group { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.control-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.field-select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 28px 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary); outline: none; cursor: pointer; appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 8px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.field-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.field-select:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.field-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary); outline: none;
|
||||||
|
appearance: none; -moz-appearance: textfield;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.field-input:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.field-input:focus { border-color: var(--accent); color: var(--text-primary); }
|
||||||
|
.field-input:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.field-input::-webkit-outer-spin-button,
|
||||||
|
.field-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
|
||||||
|
.meta-section { display: flex; flex-direction: column; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||||
|
.meta-key {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
min-width: 72px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1);
|
||||||
|
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
}
|
||||||
|
.confirm-modal {
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-xl); padding: var(--sp-6);
|
||||||
|
width: 300px; max-width: calc(100vw - 32px);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||||
|
animation: scaleIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
.confirm-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
|
||||||
|
color: var(--color-error); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); text-align: center; margin: 0; }
|
||||||
|
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0; }
|
||||||
|
.confirm-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.confirm-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
|
||||||
|
.confirm-cancel {
|
||||||
|
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 8px 0; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: none;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.confirm-confirm {
|
||||||
|
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 8px 0; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
|
||||||
|
color: var(--color-error); cursor: pointer;
|
||||||
|
transition: filter var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
|
||||||
|
|
||||||
|
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowsClockwise, MagnifyingGlass } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { SortKey } from "../lib/trackingSync";
|
||||||
|
|
||||||
|
interface Tracker { id: number; name: string; icon: string; trackRecords: { nodes: any[] }; isLoggedIn: boolean; }
|
||||||
|
interface StatusOption { value: number; name: string; }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loggedIn: Tracker[];
|
||||||
|
totalCount: number;
|
||||||
|
activeTrackerId: number | "all";
|
||||||
|
statusFilter: number | "all";
|
||||||
|
statusOptions: StatusOption[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: SortKey;
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onTrackerChange: (id: number | "all") => void;
|
||||||
|
onStatusChange: (v: number | "all") => void;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
|
onSortChange: (v: SortKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
loggedIn, totalCount, activeTrackerId, statusFilter, statusOptions,
|
||||||
|
searchQuery, sortBy, loading,
|
||||||
|
onRefresh, onTrackerChange, onStatusChange, onSearchChange, onSortChange,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-top">
|
||||||
|
<span class="heading">Tracking</span>
|
||||||
|
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
|
||||||
|
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !loading && loggedIn.length > 0}
|
||||||
|
<div class="tracker-tabs">
|
||||||
|
<button
|
||||||
|
class="tracker-tab" class:active={activeTrackerId === "all"}
|
||||||
|
onclick={() => onTrackerChange("all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span class="tab-count">{totalCount}</span>
|
||||||
|
</button>
|
||||||
|
{#each loggedIn as t}
|
||||||
|
<button
|
||||||
|
class="tracker-tab" class:active={activeTrackerId === t.id}
|
||||||
|
onclick={() => onTrackerChange(t.id)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||||
|
{t.name}
|
||||||
|
<span class="tab-count">{t.trackRecords.nodes.length}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={13} weight="light" class="search-ico" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="pill-select"
|
||||||
|
value={statusFilter}
|
||||||
|
onchange={(e) => {
|
||||||
|
const v = (e.target as HTMLSelectElement).value;
|
||||||
|
onStatusChange(v === "all" ? "all" : parseInt(v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{#each statusOptions as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="pill-select" value={sortBy} onchange={(e) => onSortChange((e.target as HTMLSelectElement).value as SortKey)}>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
<option value="score">Score</option>
|
||||||
|
<option value="progress">Progress</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
|
.toolbar-top {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.tracker-tabs {
|
||||||
|
display: flex; align-items: center; gap: 1px;
|
||||||
|
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.tracker-tab {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 10px 7px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
color: var(--text-faint); background: none; border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.tracker-tab:hover { color: var(--text-muted); }
|
||||||
|
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||||
|
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-overlay); color: var(--text-faint); line-height: 15px;
|
||||||
|
}
|
||||||
|
.tracker-tab.active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-5) var(--sp-3);
|
||||||
|
}
|
||||||
|
.search-wrap {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 5px 10px;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within { border-color: var(--border-strong); }
|
||||||
|
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.search-input {
|
||||||
|
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.pill-select {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 22px 5px 9px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
|
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 7px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
|
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { Tracker, TrackRecord } from "@types/index";
|
import type { Tracker, TrackRecord } from "@types/index";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
|
||||||
|
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
|
||||||
|
|
||||||
export interface TrackerWithRecords extends Tracker {
|
export interface TrackerWithRecords extends Tracker {
|
||||||
trackRecords: { nodes: TrackRecord[] };
|
trackRecords: { nodes: TrackRecord[] };
|
||||||
@@ -109,3 +112,47 @@ export function removeRecord(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncBackOptions {
|
||||||
|
respectScanlatorFilter: boolean;
|
||||||
|
chapterPrefs: ChapterDisplayPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncBackFromTracker(
|
||||||
|
records: TrackRecord[],
|
||||||
|
chapters: Chapter[],
|
||||||
|
opts: SyncBackOptions,
|
||||||
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
|
||||||
|
const eligible = buildChapterList(
|
||||||
|
opts.respectScanlatorFilter ? buildChapterList(chapters, opts.chapterPrefs) : chapters,
|
||||||
|
{ ...opts.chapterPrefs, sortDir: "asc" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const toMarkRead: number[] = [];
|
||||||
|
const toMarkUnread: number[] = [];
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const remote = record.lastChapterRead;
|
||||||
|
if (!remote || remote <= 0) continue;
|
||||||
|
|
||||||
|
const position = Math.round(remote);
|
||||||
|
const below = eligible.slice(0, position);
|
||||||
|
const above = eligible.slice(position);
|
||||||
|
|
||||||
|
toMarkRead.push(...below.filter(c => !c.isRead).map(c => c.id));
|
||||||
|
toMarkUnread.push(...above.filter(c => c.isRead).map(c => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const readIds = [...new Set(toMarkRead)];
|
||||||
|
const unreadIds = [...new Set(toMarkUnread)];
|
||||||
|
|
||||||
|
if (readIds.length > 0) {
|
||||||
|
await gqlFn(MARK_CHAPTERS_READ, { ids: readIds, isRead: true });
|
||||||
|
}
|
||||||
|
if (unreadIds.length > 0) {
|
||||||
|
await gqlFn(MARK_CHAPTERS_READ, { ids: unreadIds, isRead: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markedRead: readIds, markedUnread: unreadIds };
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { gql } from "@api/client";
|
||||||
|
import { GET_MANGA_TRACK_RECORDS, GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
|
||||||
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import { UPDATE_TRACK, FETCH_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
|
||||||
|
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
|
||||||
|
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
|
||||||
|
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
|
||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import type { TrackRecord, Tracker } from "@types/index";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
import type { TrackerWithRecords } from "@features/tracking/lib/trackingSync";
|
||||||
|
|
||||||
|
const BOOT_SYNC_RATE_MS = 400;
|
||||||
|
|
||||||
|
type RecordMap = Map<number, TrackRecord[]>;
|
||||||
|
type MangaBucket = { mangaId: number; records: TrackRecord[] };
|
||||||
|
|
||||||
|
class TrackingState {
|
||||||
|
private byManga: RecordMap = $state(new Map());
|
||||||
|
|
||||||
|
allTrackers: TrackerWithRecords[] = $state([]);
|
||||||
|
loadingAll: boolean = $state(false);
|
||||||
|
loadingFor: Set<number> = $state(new Set());
|
||||||
|
error: string | null = $state(null);
|
||||||
|
|
||||||
|
recordsFor(mangaId: number): TrackRecord[] {
|
||||||
|
return this.byManga.get(mangaId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFor(mangaId: number, records: TrackRecord[]) {
|
||||||
|
const next = new Map(this.byManga);
|
||||||
|
next.set(mangaId, records);
|
||||||
|
this.byManga = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchFor(mangaId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
const records = this.recordsFor(mangaId).map(r =>
|
||||||
|
r.id === updated.id ? { ...r, ...updated } : r
|
||||||
|
);
|
||||||
|
this.setFor(mangaId, records);
|
||||||
|
|
||||||
|
this.allTrackers = this.allTrackers.map(t => ({
|
||||||
|
...t,
|
||||||
|
trackRecords: {
|
||||||
|
nodes: t.trackRecords.nodes.map(r =>
|
||||||
|
r.id === updated.id ? { ...r, ...updated } : r
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadForManga(mangaId: number) {
|
||||||
|
if (this.loadingFor.has(mangaId)) return;
|
||||||
|
const existing = this.byManga.get(mangaId);
|
||||||
|
if (existing && existing.length > 0) return;
|
||||||
|
|
||||||
|
const next = new Set(this.loadingFor);
|
||||||
|
next.add(mangaId);
|
||||||
|
this.loadingFor = next;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||||
|
GET_MANGA_TRACK_RECORDS, { mangaId }
|
||||||
|
);
|
||||||
|
this.setFor(mangaId, res.manga.trackRecords.nodes);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.message ?? "Failed to load tracking";
|
||||||
|
} finally {
|
||||||
|
const s = new Set(this.loadingFor);
|
||||||
|
s.delete(mangaId);
|
||||||
|
this.loadingFor = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAll() {
|
||||||
|
this.loadingAll = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||||
|
this.allTrackers = res.trackers.nodes;
|
||||||
|
|
||||||
|
for (const tracker of res.trackers.nodes.filter(t => t.isLoggedIn)) {
|
||||||
|
for (const record of tracker.trackRecords.nodes) {
|
||||||
|
if (!record.manga?.id) continue;
|
||||||
|
const mangaId = record.manga.id;
|
||||||
|
const existing = this.byManga.get(mangaId) ?? [];
|
||||||
|
const merged = [...existing.filter(r => r.id !== record.id), record];
|
||||||
|
this.setFor(mangaId, merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.message ?? "Failed to load tracking";
|
||||||
|
} finally {
|
||||||
|
this.loadingAll = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, status }
|
||||||
|
);
|
||||||
|
this.patchFor(mangaId, res.updateTrack.trackRecord);
|
||||||
|
return res.updateTrack.trackRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise<TrackRecord> {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||||
|
);
|
||||||
|
this.patchFor(mangaId, res.updateTrack.trackRecord);
|
||||||
|
return res.updateTrack.trackRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise<TrackRecord> {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead }
|
||||||
|
);
|
||||||
|
this.patchFor(mangaId, res.updateTrack.trackRecord);
|
||||||
|
return res.updateTrack.trackRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
async unbind(mangaId: number, record: TrackRecord) {
|
||||||
|
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||||
|
this.setFor(mangaId, this.recordsFor(mangaId).filter(r => r.id !== record.id));
|
||||||
|
this.allTrackers = this.allTrackers.map(t => ({
|
||||||
|
...t,
|
||||||
|
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFromRemote(
|
||||||
|
mangaId: number,
|
||||||
|
record: TrackRecord,
|
||||||
|
chapters: Chapter[],
|
||||||
|
prefs: ChapterDisplayPrefs,
|
||||||
|
): Promise<{ fresh: TrackRecord; markedIds: number[] }> {
|
||||||
|
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
FETCH_TRACK, { recordId: record.id }
|
||||||
|
);
|
||||||
|
const fresh = res.fetchTrack.trackRecord;
|
||||||
|
this.patchFor(mangaId, fresh);
|
||||||
|
|
||||||
|
const { markedRead } = await this._applyRemoteProgress(fresh, chapters, prefs);
|
||||||
|
return { fresh, markedIds: markedRead };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _applyRemoteProgress(
|
||||||
|
record: TrackRecord,
|
||||||
|
chapters: Chapter[],
|
||||||
|
prefs: ChapterDisplayPrefs,
|
||||||
|
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
|
||||||
|
if (!store.settings.trackerSyncBack) return { markedRead: [], markedUnread: [] };
|
||||||
|
|
||||||
|
return syncBackFromTracker(
|
||||||
|
[record],
|
||||||
|
chapters,
|
||||||
|
{
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromRead(
|
||||||
|
mangaId: number,
|
||||||
|
chapter: Chapter,
|
||||||
|
chapterList: Chapter[],
|
||||||
|
prefs: ChapterDisplayPrefs,
|
||||||
|
) {
|
||||||
|
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
|
||||||
|
const idx = filtered.findIndex(c => c.id === chapter.id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const position = idx + 1;
|
||||||
|
|
||||||
|
const records = this.recordsFor(mangaId);
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
const completedValue = this._completedStatusFor(record.trackerId);
|
||||||
|
const isCompleted = completedValue !== null && record.status === completedValue;
|
||||||
|
const readingValue = this._readingStatusFor(record.trackerId);
|
||||||
|
const belowMax = record.totalChapters > 0 && (record.lastChapterRead ?? 0) < record.totalChapters;
|
||||||
|
|
||||||
|
if ((isCompleted || belowMax) && readingValue !== null && position > (record.lastChapterRead ?? 0)) {
|
||||||
|
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
|
||||||
|
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
|
||||||
|
} else if (!isCompleted && position > (record.lastChapterRead ?? 0)) {
|
||||||
|
await this.updateChapterProgress(mangaId, record, position);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromUnread(
|
||||||
|
mangaId: number,
|
||||||
|
chapterList: Chapter[],
|
||||||
|
prefs: ChapterDisplayPrefs,
|
||||||
|
) {
|
||||||
|
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
|
||||||
|
const lastRead = [...filtered].reverse().find(c => c.isRead);
|
||||||
|
const position = lastRead ? filtered.findIndex(c => c.id === lastRead.id) + 1 : 0;
|
||||||
|
|
||||||
|
const records = this.recordsFor(mangaId);
|
||||||
|
for (const record of records.filter(r => (r.lastChapterRead ?? 0) > position)) {
|
||||||
|
try {
|
||||||
|
const completedValue = this._completedStatusFor(record.trackerId);
|
||||||
|
const isCompleted = completedValue !== null && record.status === completedValue;
|
||||||
|
const belowMax = record.totalChapters > 0 && position < record.totalChapters;
|
||||||
|
const readingValue = this._readingStatusFor(record.trackerId);
|
||||||
|
|
||||||
|
if ((isCompleted || belowMax) && readingValue !== null) {
|
||||||
|
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
|
||||||
|
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
|
||||||
|
} else {
|
||||||
|
await this.updateChapterProgress(mangaId, record, position);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(mangaId: number) {
|
||||||
|
const next = new Map(this.byManga);
|
||||||
|
next.delete(mangaId);
|
||||||
|
this.byManga = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _statusesFor(trackerId: number): { value: number; name: string }[] {
|
||||||
|
return this.allTrackers.find(t => t.id === trackerId)?.statuses ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _completedStatusFor(trackerId: number): number | null {
|
||||||
|
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "completed");
|
||||||
|
return s?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _readingStatusFor(trackerId: number): number | null {
|
||||||
|
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "reading");
|
||||||
|
return s?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootSync() {
|
||||||
|
if (!store.settings.trackerSyncBack) return;
|
||||||
|
|
||||||
|
if (this.allTrackers.length === 0) await this.loadAll();
|
||||||
|
|
||||||
|
const buckets = new Map<number, MangaBucket>();
|
||||||
|
|
||||||
|
for (const tracker of this.allTrackers.filter(t => t.isLoggedIn)) {
|
||||||
|
const completedValue = this._completedStatusFor(tracker.id);
|
||||||
|
for (const record of tracker.trackRecords.nodes) {
|
||||||
|
const mangaId = record.manga?.id;
|
||||||
|
if (!mangaId) continue;
|
||||||
|
if (completedValue !== null && record.status === completedValue) continue;
|
||||||
|
const bucket = buckets.get(mangaId) ?? { mangaId, records: [] };
|
||||||
|
bucket.records.push(record);
|
||||||
|
buckets.set(mangaId, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
for (const { mangaId, records } of buckets.values()) {
|
||||||
|
const prefs = { ...(store.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs;
|
||||||
|
|
||||||
|
let chapters: Chapter[];
|
||||||
|
try {
|
||||||
|
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
|
chapters = res.chapters.nodes;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshRecords: TrackRecord[] = [];
|
||||||
|
for (const record of records) {
|
||||||
|
await delay(BOOT_SYNC_RATE_MS);
|
||||||
|
try {
|
||||||
|
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
|
||||||
|
const fresh = res.fetchTrack.trackRecord;
|
||||||
|
this.patchFor(mangaId, fresh);
|
||||||
|
freshRecords.push(fresh);
|
||||||
|
} catch {
|
||||||
|
freshRecords.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncBackFromTracker(
|
||||||
|
freshRecords,
|
||||||
|
chapters,
|
||||||
|
{
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackingState = new TrackingState();
|
||||||
@@ -43,6 +43,6 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
|
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
|
||||||
.shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); min-height: 0; min-width: 0; }
|
.shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); background-image: var(--bg-image); min-height: 0; min-width: 0; }
|
||||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
|
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
|
||||||
</style>
|
</style>
|
||||||
@@ -113,7 +113,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ringFull) return;
|
if (!ringFull) {
|
||||||
|
exitLock = false;
|
||||||
|
exiting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
cancelAnimationFrame(animFrame);
|
cancelAnimationFrame(animFrame);
|
||||||
ringProg = 1;
|
ringProg = 1;
|
||||||
if (lockEnabled && !pinUnlocked) {
|
if (lockEnabled && !pinUnlocked) {
|
||||||
@@ -163,8 +167,6 @@
|
|||||||
return () => clearInterval(dotsInterval);
|
return () => clearInterval(dotsInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Canvas card animation ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 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 CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||||
@@ -177,7 +179,6 @@
|
|||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
const BUF = 80, COLS = 14;
|
||||||
|
|
||||||
// Deterministic per-index hash — no random(), same layout every mount
|
|
||||||
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);
|
||||||
@@ -275,7 +276,6 @@
|
|||||||
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;
|
||||||
// Fade in at entry, fade out at exit
|
|
||||||
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;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { probeServer, loginBasic } from "@core/auth";
|
import { probeServer, loginBasic } from "@core/auth";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 10;
|
const MAX_ATTEMPTS = 40;
|
||||||
|
|
||||||
export const boot = $state({
|
export const boot = $state({
|
||||||
serverProbeOk: false,
|
serverProbeOk: false,
|
||||||
@@ -15,24 +16,25 @@ export const boot = $state({
|
|||||||
loginBusy: false,
|
loginBusy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let cancelProbe = false;
|
let probeGeneration = 0;
|
||||||
|
|
||||||
export function startProbe() {
|
export function startProbe() {
|
||||||
cancelProbe = false;
|
const gen = ++probeGeneration;
|
||||||
boot.failed = false;
|
boot.failed = false;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.unsupportedMode = false;
|
boot.unsupportedMode = false;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
|
|
||||||
async function probe() {
|
async function probe() {
|
||||||
if (cancelProbe) return;
|
if (gen !== probeGeneration) return;
|
||||||
tries++;
|
tries++;
|
||||||
const result = await probeServer();
|
const result = await probeServer();
|
||||||
if (cancelProbe) return;
|
if (gen !== probeGeneration) return;
|
||||||
|
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
boot.serverProbeOk = true;
|
boot.serverProbeOk = true;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
|
trackingState.bootSync().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ export function startProbe() {
|
|||||||
try {
|
try {
|
||||||
await loginBasic(savedUser, savedPass);
|
await loginBasic(savedUser, savedPass);
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
|
trackingState.bootSync().catch(() => {});
|
||||||
return;
|
return;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -59,14 +62,15 @@ export function startProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
||||||
setTimeout(probe, 750);
|
const delay = Math.min(750 + tries * 250, 3000);
|
||||||
|
setTimeout(probe, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
setTimeout(probe, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopProbe() {
|
export function stopProbe() {
|
||||||
cancelProbe = true;
|
probeGeneration++;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitLogin(onSuccess: () => void) {
|
export async function submitLogin(onSuccess: () => void) {
|
||||||
@@ -81,6 +85,7 @@ export async function submitLogin(onSuccess: () => void) {
|
|||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.loginPass = "";
|
boot.loginPass = "";
|
||||||
boot.loginError = null;
|
boot.loginError = null;
|
||||||
|
trackingState.bootSync().catch(() => {});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
boot.loginError = e?.message ?? "Login failed";
|
boot.loginError = e?.message ?? "Login failed";
|
||||||
@@ -99,7 +104,7 @@ export function retryBoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bypassBoot(onReady: () => void) {
|
export function bypassBoot(onReady: () => void) {
|
||||||
cancelProbe = true;
|
probeGeneration++;
|
||||||
boot.serverProbeOk = true;
|
boot.serverProbeOk = true;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.unsupportedMode = false;
|
boot.unsupportedMode = false;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Manga, Chapter } from "@types";
|
|||||||
const APP_ID = "1487894643613106298";
|
const APP_ID = "1487894643613106298";
|
||||||
const FALLBACK_IMAGE = "moku_logo";
|
const FALLBACK_IMAGE = "moku_logo";
|
||||||
const BUTTONS = [
|
const BUTTONS = [
|
||||||
{ label: "GitHub", url: "https://github.com/Youwes09/Moku" },
|
{ label: "GitHub", url: "https://github.com/moku-project/Moku" },
|
||||||
{ label: "Discord", url: "https://discord.gg/Jq3pwuNqPp" },
|
{ label: "Discord", url: "https://discord.gg/Jq3pwuNqPp" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +1,27 @@
|
|||||||
import type { Manga, Chapter, Category, Source } from "../types";
|
import type { Manga, Chapter, Category, Source } from "../types";
|
||||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../core/keybinds/defaultBinds";
|
import type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
|
||||||
|
LibraryFilter } from "../types/settings";
|
||||||
|
import type { HistoryEntry, BookmarkEntry, MarkerEntry, MarkerColor,
|
||||||
|
ReadLogEntry, ReadingStats, LibraryUpdateEntry } from "../types/history";
|
||||||
|
import { DEFAULT_KEYBINDS } from "../core/keybinds/defaultBinds";
|
||||||
|
import { DEFAULT_SETTINGS } from "../types/settings";
|
||||||
|
import { DEFAULT_READING_STATS } from "../types/history";
|
||||||
import { notifications } from "./notifications.svelte";
|
import { notifications } from "./notifications.svelte";
|
||||||
import { app } from "./app.svelte";
|
import { app } from "./app.svelte";
|
||||||
|
|
||||||
export type { NavPage } from "./app.svelte";
|
export type { NavPage } from "./app.svelte";
|
||||||
export type { Toast, ActiveDownload } from "./notifications.svelte";
|
export type { Toast, ActiveDownload } from "./notifications.svelte";
|
||||||
|
export type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
LibraryFilter, LibrarySortMode, LibrarySortDir,
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
LibraryStatusFilter, LibraryContentFilter,
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
PageStyle, FitMode, ReadingDirection,
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
ChapterSortDir, ChapterSortMode,
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
BuiltinTheme, Theme, ThemeTokens,
|
||||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
MangaPrefs } from "../types/settings";
|
||||||
|
export { DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS,
|
||||||
export type LibrarySortMode =
|
DEFAULT_THEME_TOKENS } from "../types/settings";
|
||||||
| "az" | "unreadCount" | "totalChapters"
|
export type { HistoryEntry, BookmarkEntry, MarkerEntry, MarkerColor,
|
||||||
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded";
|
ReadLogEntry, ReadingStats, LibraryUpdateEntry } from "../types/history";
|
||||||
|
|
||||||
export type LibrarySortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
|
||||||
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
|
||||||
|
|
||||||
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
|
||||||
export type Theme = BuiltinTheme | string;
|
|
||||||
|
|
||||||
export interface ThemeTokens {
|
|
||||||
"bg-void": string; "bg-base": string; "bg-surface": string;
|
|
||||||
"bg-raised": string; "bg-overlay": string; "bg-subtle": string;
|
|
||||||
"border-dim": string; "border-base": string; "border-strong": string; "border-focus": string;
|
|
||||||
"text-primary": string; "text-secondary": string; "text-muted": string;
|
|
||||||
"text-faint": string; "text-disabled": string;
|
|
||||||
"accent": string; "accent-dim": string; "accent-muted": string;
|
|
||||||
"accent-fg": string; "accent-bright": string;
|
|
||||||
"color-error": string; "color-error-bg": string;
|
|
||||||
"color-success": string; "color-info": string; "color-info-bg": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomTheme { id: string; name: string; tokens: ThemeTokens; }
|
|
||||||
|
|
||||||
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
|
||||||
"bg-void": "#080808", "bg-base": "#0c0c0c", "bg-surface": "#101010",
|
|
||||||
"bg-raised": "#151515", "bg-overlay": "#1a1a1a", "bg-subtle": "#202020",
|
|
||||||
"border-dim": "#1c1c1c", "border-base": "#242424", "border-strong": "#2e2e2e", "border-focus": "#4a5c4a",
|
|
||||||
"text-primary": "#f0efec", "text-secondary": "#c8c6c0", "text-muted": "#8a8880",
|
|
||||||
"text-faint": "#4e4d4a", "text-disabled": "#2a2a28",
|
|
||||||
"accent": "#6b8f6b", "accent-dim": "#2a3d2a", "accent-muted": "#1a251a",
|
|
||||||
"accent-fg": "#a8c4a8", "accent-bright": "#8fb88f",
|
|
||||||
"color-error": "#c47a7a", "color-error-bg": "#1f1212",
|
|
||||||
"color-success": "#7aab7a", "color-info": "#7a9ec4", "color-info-bg": "#121a1f",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
chapterId: number; chapterName: string; readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookmarkEntry {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
chapterId: number; chapterName: string; pageNumber: number;
|
|
||||||
savedAt: number; label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
|
|
||||||
|
|
||||||
export interface MarkerEntry {
|
|
||||||
id: string; mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
chapterId: number; chapterName: string; pageNumber: number;
|
|
||||||
note: string; color: MarkerColor; createdAt: number; updatedAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadLogEntry { mangaId: number; chapterId: number; readAt: number; minutes: number; }
|
|
||||||
export interface ReadingStats {
|
|
||||||
totalChaptersRead: number; totalMangaRead: number; totalMinutesRead: number;
|
|
||||||
firstReadAt: number; lastReadAt: number;
|
|
||||||
currentStreakDays: number; longestStreakDays: number; lastStreakDate: string;
|
|
||||||
}
|
|
||||||
export interface LibraryUpdateEntry {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string; newChapters: number; checkedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MangaPrefs {
|
|
||||||
autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean;
|
|
||||||
deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean;
|
|
||||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
|
||||||
preferredScanlator: string; scanlatorFilter: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
|
||||||
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
|
||||||
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
|
||||||
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
|
|
||||||
readerZoom: number; pageGap: boolean; optimizeContrast: boolean;
|
|
||||||
offsetDoubleSpreads: boolean; preloadPages: number;
|
|
||||||
autoMarkRead: boolean; autoNextChapter: boolean;
|
|
||||||
libraryCropCovers: boolean; libraryPageSize: number;
|
|
||||||
showNsfw: boolean; discordRpc: boolean;
|
|
||||||
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
|
||||||
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
|
||||||
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
|
||||||
preferredExtensionLang: string; keybinds: Keybinds;
|
|
||||||
idleTimeoutMin?: number; splashCards?: boolean;
|
|
||||||
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
|
|
||||||
autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number;
|
|
||||||
heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>;
|
|
||||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
|
||||||
serverAuthUser: string; serverAuthPass: string;
|
|
||||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
|
||||||
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
|
||||||
socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string;
|
|
||||||
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
|
||||||
flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrFallback: boolean;
|
|
||||||
appLockEnabled: boolean; appLockPin: string;
|
|
||||||
customThemes: CustomTheme[]; hiddenCategoryIds: number[];
|
|
||||||
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean;
|
|
||||||
nsfwFilteredTags: string[]; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[];
|
|
||||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
|
||||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
|
||||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
|
||||||
maxPageWidth?: number; uiScale?: number;
|
|
||||||
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string;
|
|
||||||
qolAnimations: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
|
||||||
totalChaptersRead: 0, totalMangaRead: 0, totalMinutesRead: 0,
|
|
||||||
firstReadAt: 0, lastReadAt: 0, currentStreakDays: 0, longestStreakDays: 0, lastStreakDate: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
|
||||||
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
|
||||||
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
|
|
||||||
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
|
|
||||||
libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, discordRpc: false,
|
|
||||||
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
|
||||||
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
|
||||||
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
|
|
||||||
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
|
||||||
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
|
||||||
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
|
|
||||||
theme: "dark", libraryBranches: true, renderLimit: 48,
|
|
||||||
heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {},
|
|
||||||
serverAuthUser: "", serverAuthPass: "", serverAuthMode: "NONE",
|
|
||||||
socksProxyEnabled: false, socksProxyHost: "", socksProxyPort: "1080",
|
|
||||||
socksProxyVersion: 5, socksProxyUsername: "", socksProxyPassword: "",
|
|
||||||
flareSolverrEnabled: false, flareSolverrUrl: "http://localhost:8191",
|
|
||||||
flareSolverrTimeout: 60, flareSolverrSessionName: "moku",
|
|
||||||
flareSolverrSessionTtl: 15, flareSolverrFallback: false,
|
|
||||||
appLockEnabled: false, appLockPin: "",
|
|
||||||
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
|
|
||||||
savedIsDefaultCategory: false,
|
|
||||||
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
|
||||||
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
|
|
||||||
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
|
||||||
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
|
|
||||||
qolAnimations: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STORE_VERSION = 3;
|
const STORE_VERSION = 3;
|
||||||
const AVG_MIN_PER_CHAPTER = 5;
|
const AVG_MIN_PER_CHAPTER = 5;
|
||||||
@@ -204,6 +66,9 @@ function mergeSettings(saved: any): Settings {
|
|||||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||||
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||||
|
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
||||||
|
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||||
|
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +89,7 @@ class Store {
|
|||||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||||
|
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
|
||||||
searchCache: Map<string, any> = $state(new Map());
|
searchCache: Map<string, any> = $state(new Map());
|
||||||
searchLibraryIds: Set<number> = $state(new Set());
|
searchLibraryIds: Set<number> = $state(new Set());
|
||||||
searchSrcOffset: number = $state(0);
|
searchSrcOffset: number = $state(0);
|
||||||
@@ -250,6 +116,7 @@ class Store {
|
|||||||
settings: this.settings, history: this.history,
|
settings: this.settings, history: this.history,
|
||||||
bookmarks: this.bookmarks, markers: this.markers,
|
bookmarks: this.bookmarks, markers: this.markers,
|
||||||
readLog: this.readLog, readingStats: this.readingStats,
|
readLog: this.readLog, readingStats: this.readingStats,
|
||||||
|
dailyReadCounts: this.dailyReadCounts,
|
||||||
libraryUpdates: this.libraryUpdates,
|
libraryUpdates: this.libraryUpdates,
|
||||||
lastLibraryRefresh: this.lastLibraryRefresh,
|
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||||
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
||||||
@@ -288,6 +155,8 @@ class Store {
|
|||||||
lastReadAt: entry.readAt, currentStreakDays: streak,
|
lastReadAt: entry.readAt, currentStreakDays: streak,
|
||||||
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
||||||
};
|
};
|
||||||
|
const dayKey = new Date().toISOString().slice(0, 10);
|
||||||
|
this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +183,7 @@ class Store {
|
|||||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
||||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
||||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
|
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
|
||||||
clearHistory() { this.history = []; this.readLog = []; }
|
clearHistory() { this.history = []; this.readLog = []; this.dailyReadCounts = {}; }
|
||||||
|
|
||||||
clearHistoryForManga(mangaId: number) {
|
clearHistoryForManga(mangaId: number) {
|
||||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
@@ -329,6 +198,7 @@ class Store {
|
|||||||
|
|
||||||
wipeAllData() {
|
wipeAllData() {
|
||||||
this.history = []; this.readLog = []; this.markers = [];
|
this.history = []; this.readLog = []; this.markers = [];
|
||||||
|
this.dailyReadCounts = {};
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
}
|
||||||
@@ -404,6 +274,35 @@ class Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePinnedSource(sourceId: string) {
|
||||||
|
const pins = this.settings.pinnedSourceIds ?? [];
|
||||||
|
this.settings = { ...this.settings, pinnedSourceIds: pins.includes(sourceId) ? pins.filter(id => id !== sourceId) : [...pins, sourceId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
saveReaderPreset(name: string, settings: ReaderSettings): string {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
this.settings = { ...this.settings, readerPresets: [...(this.settings.readerPresets ?? []), { id, name: name.trim() || "Preset", settings }] };
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
|
||||||
|
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).map(p => p.id === id ? { ...p, ...patch } : p) };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReaderPreset(id: string) {
|
||||||
|
this.settings = { ...this.settings, readerPresets: (this.settings.readerPresets ?? []).filter(p => p.id !== id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
setMangaReaderSettings(mangaId: number, settings: ReaderSettings) {
|
||||||
|
this.settings = { ...this.settings, mangaReaderSettings: { ...(this.settings.mangaReaderSettings ?? {}), [mangaId]: settings } };
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMangaReaderSettings(mangaId: number) {
|
||||||
|
const next = { ...(this.settings.mangaReaderSettings ?? {}) };
|
||||||
|
delete next[mangaId];
|
||||||
|
this.settings = { ...this.settings, mangaReaderSettings: next };
|
||||||
|
}
|
||||||
|
|
||||||
setCategories(cats: Category[]) { this.categories = cats; }
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||||
@@ -436,6 +335,12 @@ export function setPageUrls(next: string[])
|
|||||||
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||||
|
export function togglePinnedSource(sourceId: string) { store.togglePinnedSource(sourceId); }
|
||||||
|
export function saveReaderPreset(name: string, settings: ReaderSettings): string { return store.saveReaderPreset(name, settings); }
|
||||||
|
export function updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) { store.updateReaderPreset(id, patch); }
|
||||||
|
export function deleteReaderPreset(id: string) { store.deleteReaderPreset(id); }
|
||||||
|
export function setMangaReaderSettings(mangaId: number, settings: ReaderSettings) { store.setMangaReaderSettings(mangaId, settings); }
|
||||||
|
export function clearMangaReaderSettings(mangaId: number) { store.clearMangaReaderSettings(mangaId); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function clearSearchCache() { store.clearSearchCache(); }
|
export function clearSearchCache() { store.clearSearchCache(); }
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface HistoryEntry {
|
||||||
|
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||||
|
chapterId: number; chapterName: string; readAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkEntry {
|
||||||
|
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||||
|
chapterId: number; chapterName: string; pageNumber: number;
|
||||||
|
savedAt: number; label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple";
|
||||||
|
|
||||||
|
export interface MarkerEntry {
|
||||||
|
id: string; mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||||
|
chapterId: number; chapterName: string; pageNumber: number;
|
||||||
|
note: string; color: MarkerColor; createdAt: number; updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadLogEntry { mangaId: number; chapterId: number; readAt: number; minutes: number; }
|
||||||
|
|
||||||
|
export interface ReadingStats {
|
||||||
|
totalChaptersRead: number; totalMangaRead: number; totalMinutesRead: number;
|
||||||
|
firstReadAt: number; lastReadAt: number;
|
||||||
|
currentStreakDays: number; longestStreakDays: number; lastStreakDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
|
totalChaptersRead: 0, totalMangaRead: 0, totalMinutesRead: 0,
|
||||||
|
firstReadAt: 0, lastReadAt: 0, currentStreakDays: 0, longestStreakDays: 0, lastStreakDate: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LibraryUpdateEntry {
|
||||||
|
mangaId: number; mangaTitle: string; thumbnailUrl: string; newChapters: number; checkedAt: number;
|
||||||
|
}
|
||||||
@@ -3,3 +3,5 @@ export * from "./chapter";
|
|||||||
export * from "./extension";
|
export * from "./extension";
|
||||||
export * from "./tracking";
|
export * from "./tracking";
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
|
export * from "./settings";
|
||||||
|
export * from "./history";
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { DEFAULT_KEYBINDS, type Keybinds } from "../core/keybinds/defaultBinds";
|
||||||
|
|
||||||
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
|
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||||
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
|
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||||
|
|
||||||
|
export type LibrarySortMode =
|
||||||
|
| "az" | "unreadCount" | "totalChapters"
|
||||||
|
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded";
|
||||||
|
|
||||||
|
export type LibrarySortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||||
|
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
||||||
|
|
||||||
|
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm" | "starry";
|
||||||
|
export type Theme = BuiltinTheme | string;
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
"bg-void": string; "bg-base": string; "bg-surface": string;
|
||||||
|
"bg-raised": string; "bg-overlay": string; "bg-subtle": string;
|
||||||
|
"border-dim": string; "border-base": string; "border-strong": string; "border-focus": string;
|
||||||
|
"text-primary": string; "text-secondary": string; "text-muted": string;
|
||||||
|
"text-faint": string; "text-disabled": string;
|
||||||
|
"accent": string; "accent-dim": string; "accent-muted": string;
|
||||||
|
"accent-fg": string; "accent-bright": string;
|
||||||
|
"color-error": string; "color-error-bg": string;
|
||||||
|
"color-success": string; "color-info": string; "color-info-bg": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomTheme { id: string; name: string; tokens: ThemeTokens; }
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
||||||
|
"bg-void": "#080808", "bg-base": "#0c0c0c", "bg-surface": "#101010",
|
||||||
|
"bg-raised": "#151515", "bg-overlay": "#1a1a1a", "bg-subtle": "#202020",
|
||||||
|
"border-dim": "#1c1c1c", "border-base": "#242424", "border-strong": "#2e2e2e", "border-focus": "#4a5c4a",
|
||||||
|
"text-primary": "#f0efec", "text-secondary": "#c8c6c0", "text-muted": "#8a8880",
|
||||||
|
"text-faint": "#4e4d4a", "text-disabled": "#2a2a28",
|
||||||
|
"accent": "#6b8f6b", "accent-dim": "#2a3d2a", "accent-muted": "#1a251a",
|
||||||
|
"accent-fg": "#a8c4a8", "accent-bright": "#8fb88f",
|
||||||
|
"color-error": "#c47a7a", "color-error-bg": "#1f1212",
|
||||||
|
"color-success": "#7aab7a", "color-info": "#7a9ec4", "color-info-bg": "#121a1f",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MangaPrefs {
|
||||||
|
autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean;
|
||||||
|
deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean;
|
||||||
|
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||||
|
preferredScanlator: string; scanlatorFilter: string[];
|
||||||
|
autoDownloadScanlators: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||||
|
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
||||||
|
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
||||||
|
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
|
||||||
|
autoDownloadScanlators: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReaderSettings {
|
||||||
|
pageStyle: PageStyle;
|
||||||
|
fitMode: FitMode;
|
||||||
|
readingDirection: ReadingDirection;
|
||||||
|
readerZoom: number;
|
||||||
|
pageGap: boolean;
|
||||||
|
optimizeContrast: boolean;
|
||||||
|
offsetDoubleSpreads: boolean;
|
||||||
|
barPosition?: "top" | "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderPreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
settings: ReaderSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
|
||||||
|
readerZoom: number; pageGap: boolean; optimizeContrast: boolean;
|
||||||
|
offsetDoubleSpreads: boolean; preloadPages: number;
|
||||||
|
autoMarkRead: boolean; autoNextChapter: boolean;
|
||||||
|
libraryCropCovers: boolean; libraryPageSize: number;
|
||||||
|
showNsfw: boolean; discordRpc: boolean;
|
||||||
|
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
||||||
|
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
||||||
|
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
||||||
|
preferredExtensionLang: string; keybinds: Keybinds;
|
||||||
|
idleTimeoutMin?: number; splashCards?: boolean;
|
||||||
|
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
|
||||||
|
autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number;
|
||||||
|
heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>;
|
||||||
|
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||||
|
serverAuthUser: string; serverAuthPass: string;
|
||||||
|
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
|
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
||||||
|
socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string;
|
||||||
|
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
||||||
|
flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrFallback: boolean;
|
||||||
|
appLockEnabled: boolean; appLockPin: string;
|
||||||
|
customThemes: CustomTheme[]; hiddenCategoryIds: number[];
|
||||||
|
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean;
|
||||||
|
nsfwFilteredTags: string[]; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[];
|
||||||
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
|
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||||
|
maxPageWidth?: number; uiScale?: number;
|
||||||
|
extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string;
|
||||||
|
qolAnimations: boolean;
|
||||||
|
pinnedSourceIds: string[];
|
||||||
|
readerPresets: ReaderPreset[];
|
||||||
|
mangaReaderSettings: Record<number, ReaderSettings>;
|
||||||
|
barPosition?: "top" | "left" | "right";
|
||||||
|
trackerSyncBack: boolean;
|
||||||
|
trackerSyncBackThreshold: number | null;
|
||||||
|
trackerRespectScanlatorFilter: boolean;
|
||||||
|
pinchZoom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
||||||
|
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
|
||||||
|
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
|
||||||
|
libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, discordRpc: false,
|
||||||
|
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
||||||
|
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
||||||
|
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
|
||||||
|
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
||||||
|
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
||||||
|
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
|
||||||
|
theme: "dark", libraryBranches: true, renderLimit: 48,
|
||||||
|
heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {},
|
||||||
|
serverAuthUser: "", serverAuthPass: "", serverAuthMode: "NONE",
|
||||||
|
socksProxyEnabled: false, socksProxyHost: "", socksProxyPort: "1080",
|
||||||
|
socksProxyVersion: 5, socksProxyUsername: "", socksProxyPassword: "",
|
||||||
|
flareSolverrEnabled: false, flareSolverrUrl: "http://localhost:8191",
|
||||||
|
flareSolverrTimeout: 60, flareSolverrSessionName: "moku",
|
||||||
|
flareSolverrSessionTtl: 15, flareSolverrFallback: false,
|
||||||
|
appLockEnabled: false, appLockPin: "",
|
||||||
|
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
|
||||||
|
savedIsDefaultCategory: false,
|
||||||
|
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
||||||
|
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
|
||||||
|
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
||||||
|
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
|
||||||
|
qolAnimations: true,
|
||||||
|
pinnedSourceIds: [],
|
||||||
|
readerPresets: [],
|
||||||
|
mangaReaderSettings: {},
|
||||||
|
trackerSyncBack: false,
|
||||||
|
trackerSyncBackThreshold: 20,
|
||||||
|
trackerRespectScanlatorFilter: true,
|
||||||
|
pinchZoom: false,
|
||||||
|
};
|
||||||