Compare commits
32 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 |
@@ -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"
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
run: |
|
||||
# Wait for the Windows workflow to have created the draft release
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/moku-project/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||
echo "Waiting for release to exist... attempt $i"
|
||||
sleep 15
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
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/Youwes09/Moku/releases/$RELEASE_ID/assets?name=$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)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
@@ -134,12 +134,15 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
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"
|
||||
# Also delete the tag so tauri-action can recreate it
|
||||
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/releases/$RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||
echo "Deleted draft release and tag"
|
||||
else
|
||||
echo "No existing draft release found"
|
||||
@@ -149,8 +152,6 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
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:
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pkgname=moku
|
||||
pkgver=0.5.0
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Youwes09/Moku"
|
||||
license=('Apache 2.0')
|
||||
url="https://github.com/moku-project/Moku"
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
@@ -18,13 +18,13 @@ makedepends=(
|
||||
'pnpm'
|
||||
)
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/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"
|
||||
"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"
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar"
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
)
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
|
||||
prepare() {
|
||||
cd "Moku-$pkgver"
|
||||
@@ -34,7 +34,6 @@ prepare() {
|
||||
build() {
|
||||
cd "Moku-$pkgver"
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -46,10 +45,7 @@ package() {
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
@@ -66,7 +62,7 @@ server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF'
|
||||
#!/bin/sh
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
@@ -90,7 +86,7 @@ unset WAYLAND_DISPLAY
|
||||
export _JAVA_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 \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
@@ -99,16 +95,16 @@ exec /usr/lib/moku/jre/bin/java \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||
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 \
|
||||
"$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 \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](./LICENSE)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||
[](https://github.com/moku-project/Moku/commits/main)
|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -20,16 +20,20 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="49%" 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" />
|
||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -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
|
||||
- **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)
|
||||
- **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
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **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
|
||||
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
flatpak run dev.moku.app
|
||||
flatpak install io.github.moku_app.Moku
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/Moku
|
||||
nix run github:moku-project/Moku
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
|
||||
```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
|
||||
|
||||
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:
|
||||
> ```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/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/Moku
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
@@ -136,7 +158,7 @@ pnpm tauri:dev
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,4 +170,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||
|
||||
@@ -25,14 +25,14 @@ In-Progress:
|
||||
- Working on 3D Display Cards
|
||||
- Add Flathub Support (Pending Video)
|
||||
|
||||
- QOL Animations & Revamps
|
||||
- Tracking Revamp
|
||||
- Completely Revamp Tracking
|
||||
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
Notes from last time:
|
||||
|
||||
|
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, ... }:
|
||||
let
|
||||
version = "0.9.0";
|
||||
version = "0.9.1";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -149,43 +149,27 @@ EOF
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
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 = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "── Bumping version fields to $VERSION ──"
|
||||
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"
|
||||
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)
|
||||
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 "── Building frontend ──"
|
||||
@@ -199,7 +183,15 @@ EOF
|
||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
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'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
@@ -213,29 +205,70 @@ EOF
|
||||
PYEOF
|
||||
echo "Done"
|
||||
|
||||
echo "── Regenerating cargo-sources.json ──"
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
echo ""
|
||||
echo "Bumped to v$VERSION"
|
||||
echo ""
|
||||
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 "── 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"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$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"
|
||||
echo "moku.flatpak created"
|
||||
|
||||
echo ""
|
||||
echo "Done — v$VERSION"
|
||||
echo " -> $REPO/moku.flatpak"
|
||||
echo ""
|
||||
echo "After pushing the tag, run:"
|
||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
||||
echo "moku.flatpak created — v$VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -249,7 +282,7 @@ EOF
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
[[ -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..."
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
|
||||
@@ -279,6 +312,7 @@ EOF
|
||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
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"; };
|
||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||
@@ -300,6 +334,7 @@ EOF
|
||||
suwayomi-server
|
||||
cloudflared
|
||||
xdg-utils
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||
];
|
||||
shellHook = ''
|
||||
export NO_STRIP=true
|
||||
@@ -308,10 +343,11 @@ EOF
|
||||
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " nix run .#bump -- <ver> bump versions only"
|
||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||
echo "Release workflow:"
|
||||
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
|
||||
echo " git commit && git tag && git 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)"
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: io.github.Youwes09.Moku
|
||||
app-id: io.github.moku_project.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -171,19 +171,19 @@ modules:
|
||||
- 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
|
||||
- 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 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.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@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.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.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.moku_project.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.moku_project.Moku.png
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/Youwes09/Moku.git
|
||||
tag: v0.8.0
|
||||
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.9.1
|
||||
commit: 514910667b0d6e375569a48fb7cef11411d30fbd
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
||||
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=io.github.Youwes09.Moku
|
||||
Icon=io.github.moku_project.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.Youwes09.Moku</id>
|
||||
<id>io.github.moku_project.Moku</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
@@ -19,30 +19,30 @@
|
||||
</p>
|
||||
</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="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||
|
||||
<screenshots>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
@@ -54,11 +54,16 @@
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.8.0" date="2025-04-01">
|
||||
<release version="0.9.0" date="2025-04-01">
|
||||
<description>
|
||||
<p>Latest release with improved stability and UI refinements.</p>
|
||||
</description>
|
||||
</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">
|
||||
<description>
|
||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||
@@ -1701,9 +1701,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
@@ -2092,7 +2092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moku"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2811,9 +2811,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
@@ -2961,9 +2961,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3310,9 +3310,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4202,7 +4202,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest 0.13.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4526,13 +4526,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winres"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0"
|
||||
checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"embed-resource",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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"
|
||||
@@ -269,7 +269,7 @@ fn suwayomi_data_dir() -> PathBuf {
|
||||
{
|
||||
dirs::data_dir()
|
||||
.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")))]
|
||||
{
|
||||
@@ -589,7 +589,7 @@ async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
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()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -635,7 +635,7 @@ async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Resu
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let url = format!("https://api.github.com/repos/Youwes09/Moku/releases/tags/{}", tag);
|
||||
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));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.9.0",
|
||||
"identifier": "io.github.Youwes09.Moku.app",
|
||||
"version": "0.9.1",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
@@ -27,9 +27,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -49,10 +47,6 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"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": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"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";
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
@@ -34,7 +40,7 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
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`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
let themeStyleEl: HTMLStyleElement | null = null;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaHandler: (() => void) | null = null;
|
||||
|
||||
export function applyTheme() {
|
||||
const themeId = store.settings.theme ?? "dark";
|
||||
@@ -34,3 +36,32 @@ export function applyTheme() {
|
||||
themeStyleEl.textContent = css;
|
||||
document.documentElement.setAttribute("data-theme", "custom");
|
||||
}
|
||||
|
||||
function applySystemTheme(dark: boolean) {
|
||||
const themeId = dark
|
||||
? (store.settings.systemThemeDark ?? "dark")
|
||||
: (store.settings.systemThemeLight ?? "light");
|
||||
updateSettings({ theme: themeId });
|
||||
}
|
||||
|
||||
export function mountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
}
|
||||
|
||||
if (!store.settings.systemThemeSync) return;
|
||||
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
applySystemTheme(mediaQuery.matches);
|
||||
}
|
||||
|
||||
export function unmountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
mediaQuery = null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
[data-theme="high-contrast"] {
|
||||
[data-theme="dark"] {
|
||||
--bg-void: #000000;
|
||||
--bg-base: #080808;
|
||||
--bg-surface: #0d0d0d;
|
||||
@@ -22,4 +22,4 @@
|
||||
--accent-muted: #1e2e1e;
|
||||
--accent-fg: #bcd8bc;
|
||||
--accent-bright: #9fcf9f;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "./high-contrast.css";
|
||||
@import "./light-contrast.css";
|
||||
@import "./original.css";
|
||||
@import "./dark.css";
|
||||
@import "./light.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"] {
|
||||
--bg-void: #e8e6e2;
|
||||
--bg-base: #eeece8;
|
||||
--bg-surface: #f4f2ee;
|
||||
--bg-raised: #faf8f4;
|
||||
--bg-void: #d8d4ce;
|
||||
--bg-base: #e2deda;
|
||||
--bg-surface: #ece8e2;
|
||||
--bg-raised: #f5f2ec;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #f0ede8;
|
||||
--bg-subtle: #e4e0d8;
|
||||
|
||||
--border-dim: #dedad4;
|
||||
--border-base: #d0ccc6;
|
||||
--border-strong: #bbb6ae;
|
||||
--border-focus: #5a7a5a;
|
||||
--border-dim: #c4c0b8;
|
||||
--border-base: #b0aca4;
|
||||
--border-strong: #989490;
|
||||
--border-focus: #3a5a3a;
|
||||
|
||||
--text-primary: #1a1916;
|
||||
--text-secondary: #2e2c28;
|
||||
--text-muted: #5a5750;
|
||||
--text-faint: #9a9890;
|
||||
--text-disabled: #c8c4bc;
|
||||
--text-primary: #080806;
|
||||
--text-secondary: #181612;
|
||||
--text-muted: #38342e;
|
||||
--text-faint: #706c64;
|
||||
--text-disabled: #b0aca4;
|
||||
|
||||
--accent: #4a724a;
|
||||
--accent-dim: #c8dcc8;
|
||||
--accent-muted: #deeade;
|
||||
--accent-fg: #2a5a2a;
|
||||
--accent-bright: #3a6a3a;
|
||||
--accent: #2a5a2a;
|
||||
--accent-dim: #b0ccb0;
|
||||
--accent-muted: #c8dcc8;
|
||||
--accent-fg: #183818;
|
||||
--accent-bright: #1e4e1e;
|
||||
|
||||
--color-error: #a03030;
|
||||
--color-error-bg: #fce8e8;
|
||||
--color-success: #2a6a2a;
|
||||
--color-info: #2a4a7a;
|
||||
--color-info-bg: #e8eef8;
|
||||
--color-read: #e8e4dc;
|
||||
--color-error: #8a1a1a;
|
||||
--color-error-bg: #f8e0e0;
|
||||
--color-read: #e0dcd4;
|
||||
}
|
||||
|
||||
@@ -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-inactive: var(--text-faint);
|
||||
}
|
||||
|
||||
--bg-image: none;
|
||||
}
|
||||
@@ -1,39 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowUp, ArrowDown, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-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 { pageProgress } from "../lib/downloadQueue";
|
||||
|
||||
interface Props {
|
||||
item: DownloadQueueItem;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isRemoving: boolean;
|
||||
isSelected: boolean;
|
||||
selectedCount: number;
|
||||
selectedErrorCount: number;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
onClearSelect: () => void;
|
||||
item: DownloadQueueItem;
|
||||
isActive: boolean;
|
||||
isRemoving: boolean;
|
||||
isSelected: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
item, index, isActive, isFirst, isLast, isRemoving,
|
||||
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
|
||||
item, isActive, isRemoving, isSelected,
|
||||
onRemove, onRetry, onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const manga = $derived(item.chapter.manga);
|
||||
@@ -41,144 +24,6 @@
|
||||
const prog = $derived(pageProgress(item.progress, pages));
|
||||
const isError = $derived(item.state === "ERROR");
|
||||
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 to top (${selectedCount})`,
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onBatchReorderEdge("top"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({
|
||||
label: `Move to bottom (${selectedCount})`,
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onBatchReorderEdge("bottom"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
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 to top",
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "top"),
|
||||
disabled: isFirst || isActive,
|
||||
});
|
||||
entries.push({
|
||||
label: "Move to bottom",
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "bottom"),
|
||||
disabled: isLast || isActive,
|
||||
});
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -188,10 +33,6 @@
|
||||
class:row-selected={isSelected}
|
||||
class:row-removing={isRemoving}
|
||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||
oncontextmenu={openMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={cancelLongPress}
|
||||
ontouchmove={onTouchMove}
|
||||
>
|
||||
{#if manga?.thumbnailUrl}
|
||||
<div class="thumb">
|
||||
@@ -229,12 +70,6 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#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">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||
</button>
|
||||
@@ -243,10 +78,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -263,118 +94,33 @@
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||
.row.row-active { border-color: var(--accent-dim); }
|
||||
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
.thumb {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||
|
||||
.manga-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.chapter-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.progress-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;
|
||||
}
|
||||
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
|
||||
.row-active .progress-bar { opacity: 1; }
|
||||
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.pages-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
@@ -4,33 +4,22 @@
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected, batchWorking,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -41,117 +30,23 @@
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
<div class="list-header">
|
||||
<div class="info-wrap">
|
||||
<button class="info-btn" tabindex="-1" aria-label="Selection help">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<circle cx="6" cy="6" r="5.25" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="5.25" y="5" width="1.5" height="3.5" rx="0.75" fill="currentColor"/>
|
||||
<circle cx="6" cy="3.25" r="0.85" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="info-popover" role="tooltip">
|
||||
<span>Click to select</span>
|
||||
<span>Shift+click to range select</span>
|
||||
<span>Ctrl+click to toggle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
index={i}
|
||||
isActive={i === 0 && isRunning}
|
||||
isFirst={i === 0}
|
||||
isLast={i === queue.length - 1}
|
||||
isRemoving={dequeueing.has(item.chapter.id)}
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
selectedCount={selected.size}
|
||||
{selectedErrorCount}
|
||||
{batchWorking}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
{onClearSelect}
|
||||
{onBatchRemove}
|
||||
{onBatchRetry}
|
||||
{onBatchReorder}
|
||||
{onBatchReorderEdge}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
color: var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.info-btn:hover { color: var(--text-muted); }
|
||||
|
||||
.info-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-popover span {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.info-wrap:hover .info-popover { 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);
|
||||
}
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { downloadStore } from "../store/downloadState.svelte";
|
||||
import { formatEta } from "../lib/downloadQueue";
|
||||
@@ -10,6 +11,11 @@
|
||||
});
|
||||
|
||||
let selectAnchor = $state<number | null>(null);
|
||||
let moveBy = $state(1);
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||
);
|
||||
|
||||
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
@@ -39,12 +45,25 @@
|
||||
selectAnchor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
downloadStore.clearSelection();
|
||||
selectAnchor = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={downloadStore.autoRetryEnabled}
|
||||
onclick={() => downloadStore.toggleAutoRetry()}
|
||||
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
|
||||
>
|
||||
<Repeat size={14} weight="regular" />
|
||||
</button>
|
||||
{#if downloadStore.hasErrored}
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -95,147 +114,119 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" onclick={handleClickOff}>
|
||||
<div class="status-bar">
|
||||
<div class="bar-wrap">
|
||||
<div class="status-bar" onclick={handleClickOff} role="presentation">
|
||||
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||
<span class="status-text">
|
||||
{downloadStore.togglingPlay
|
||||
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
||||
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<div class="status-right">
|
||||
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||
{/if}
|
||||
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||
</div>
|
||||
{#if 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">
|
||||
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||
{/if}
|
||||
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" onclick={handleClickOff}>
|
||||
<DownloadQueue
|
||||
queue={downloadStore.queue}
|
||||
loading={downloadStore.loading}
|
||||
isRunning={downloadStore.isRunning}
|
||||
dequeueing={downloadStore.dequeueing}
|
||||
selected={downloadStore.selected}
|
||||
batchWorking={downloadStore.batchWorking}
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onSelect={handleSelect}
|
||||
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
|
||||
onBatchRemove={() => downloadStore.dequeueSelected()}
|
||||
onBatchRetry={() => downloadStore.retrySelected()}
|
||||
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
|
||||
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
|
||||
|
||||
.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) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
|
||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.status-bar { cursor: default; }
|
||||
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||
.sel-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; }
|
||||
.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.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); }
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
|
||||
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.move-input:focus { color: var(--text-primary); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
@@ -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; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isRunning, getErrored, calcSpeed, estimateEta,
|
||||
type SpeedSample,
|
||||
} from "../lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
@@ -24,17 +25,39 @@ class DownloadStore {
|
||||
pagesPerSec: number | null = $state(null);
|
||||
eta: number | null = $state(null);
|
||||
|
||||
toastsEnabled = $state(true);
|
||||
toastsEnabled = $state(true);
|
||||
autoRetryEnabled = $state(false);
|
||||
|
||||
private lastSample: SpeedSample | null = null;
|
||||
private prevQueue: DownloadQueueItem[] = [];
|
||||
private lastSample: SpeedSample | null = null;
|
||||
private prevQueue: DownloadQueueItem[] = [];
|
||||
private autoRetryHnd: AutoRetryHandle | null = null;
|
||||
|
||||
get queue() { return this.status?.queue ?? []; }
|
||||
get isRunning() { return isRunning(this.status?.state); }
|
||||
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
||||
get hasErrored() { return this.erroredIds.size > 0; }
|
||||
|
||||
toggleToasts() { 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[]) {
|
||||
if (!this.toastsEnabled) return;
|
||||
@@ -101,7 +124,10 @@ class DownloadStore {
|
||||
this.applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} 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() {
|
||||
@@ -113,6 +139,7 @@ class DownloadStore {
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
this.applyStatus(d.clearDownloader.downloadStatus);
|
||||
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.clearing = false; }
|
||||
}
|
||||
@@ -137,6 +164,7 @@ class DownloadStore {
|
||||
try {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
@@ -160,6 +188,7 @@ class DownloadStore {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
@@ -173,6 +202,7 @@ class DownloadStore {
|
||||
if (ids.length > 0) {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
@@ -254,25 +284,29 @@ class DownloadStore {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
|
||||
const pinned = this.queue.filter((i) => this.selected.has(i.chapter.id));
|
||||
const rest = this.queue.filter((i) => !this.selected.has(i.chapter.id));
|
||||
const newQueue = edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||
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 first = this.isRunning ? 1 : 0;
|
||||
const last = this.queue.length - 1;
|
||||
const last = this.queue.length - 1;
|
||||
|
||||
try {
|
||||
if (edge === "top") {
|
||||
for (const item of [...pinned].reverse()) {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: first },
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const item of pinned) {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: last },
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +8,7 @@
|
||||
panel: Panel;
|
||||
refreshing: boolean;
|
||||
updateCount: number;
|
||||
updatingAll: boolean;
|
||||
availableLangs: string[];
|
||||
langFilter: string | null;
|
||||
anims: boolean;
|
||||
@@ -18,14 +19,15 @@
|
||||
onLang: (lang: string | null) => void;
|
||||
onPanel: (p: Panel) => void;
|
||||
onRefresh: () => void;
|
||||
onUpdateAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filter, search, panel, refreshing, updateCount,
|
||||
filter, search, panel, refreshing, updateCount, updatingAll,
|
||||
availableLangs, langFilter,
|
||||
anims, tabIndicator,
|
||||
tabsEl = $bindable(),
|
||||
onFilter, onSearch, onLang, onPanel, onRefresh,
|
||||
onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -57,6 +59,11 @@
|
||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</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>
|
||||
|
||||
@@ -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::placeholder { color: var(--text-faint); }
|
||||
.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-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); }
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
let search = $state("");
|
||||
let langFilter = $state<string | null>(null);
|
||||
let working = $state(new Set<string>());
|
||||
let updatingAll = $state(false);
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel = $state<Panel>(null);
|
||||
|
||||
@@ -124,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() {
|
||||
const url = externalUrl.trim();
|
||||
const err = validateUrl(url, ".apk");
|
||||
@@ -206,13 +216,14 @@
|
||||
<div class="root anim-fade-in">
|
||||
<ExtensionFilters
|
||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||
{anims} {tabIndicator}
|
||||
{anims} {tabIndicator} {updatingAll}
|
||||
bind:tabsEl
|
||||
onFilter={setFilter}
|
||||
onSearch={(q) => search = q}
|
||||
onLang={(l) => langFilter = l}
|
||||
onPanel={openPanel}
|
||||
onRefresh={fetchFromRepo}
|
||||
onUpdateAll={updateAll}
|
||||
/>
|
||||
|
||||
{#if panel === "apk"}
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
let selectedIds: Set<number> = $state(new Set());
|
||||
let selectMode: boolean = $state(false);
|
||||
let bulkWorking: boolean = $state(false);
|
||||
let bulkMoveOpen: boolean = $state(false);
|
||||
let bulkAutomateOpen: boolean = $state(false);
|
||||
|
||||
let sortPanelOpen: boolean = $state(false);
|
||||
@@ -68,7 +67,7 @@
|
||||
let dragInsertIdx: number = $state(-1);
|
||||
let dragTabId: number | null = $state(null);
|
||||
let dragOverTabId: number | null = $state(null);
|
||||
let dropTargetTabId: number | null = $state(null);
|
||||
|
||||
|
||||
const DT_TAB = "application/x-moku-tab";
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
@@ -107,6 +106,14 @@
|
||||
items = (store.settings.libraryShowAllInSaved ?? true)
|
||||
? allManga.filter(m => m.inLibrary)
|
||||
: (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") {
|
||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
} else {
|
||||
@@ -185,7 +192,7 @@
|
||||
}
|
||||
|
||||
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 selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
|
||||
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
|
||||
@@ -299,7 +306,7 @@
|
||||
}
|
||||
|
||||
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(); })); }
|
||||
finally { bulkWorking = false; exitSelectMode(); }
|
||||
}
|
||||
@@ -417,26 +424,30 @@
|
||||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
||||
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; }
|
||||
|
||||
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; }
|
||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||
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 toIdx = sorted.findIndex(c => c.id === dropCat.id);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const reordered = [...sorted];
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
if (fromIdx < 0) return;
|
||||
const reordered = [...sorted];
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
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 }));
|
||||
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -584,13 +595,11 @@
|
||||
onRetry={() => retryCount++}
|
||||
onExitSelectMode={exitSelectMode}
|
||||
onSelectAll={selectAll}
|
||||
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }}
|
||||
onBulkMove={bulkMoveToCategory}
|
||||
onBulkRemove={bulkRemoveFromLibrary}
|
||||
onBulkAutomate={bulkAutomate}
|
||||
{bulkWorking}
|
||||
{bulkMoveOpen}
|
||||
{visibleCategories}
|
||||
onCategoryMove={bulkMoveToCategory}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -91,31 +91,35 @@
|
||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each visibleCategories as cat, idx}
|
||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={tab === String(cat.id)}
|
||||
class:tab-dragging={dragTabId === cat.id}
|
||||
class:tab-drop-target={dragOverTabId === cat.id}
|
||||
draggable="true"
|
||||
onclick={() => onTabChange(String(cat.id))}
|
||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
||||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
||||
ondragleave={onTabDragLeave}
|
||||
ondrop={(e) => onTabDrop(e, cat)}
|
||||
ondragend={onTabDragEnd}
|
||||
>
|
||||
<Folder size={11} weight="bold" />
|
||||
{cat.name}
|
||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
||||
</button>
|
||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if visibleCategories.length > 0}
|
||||
<div class="tab-separator" aria-hidden="true"></div>
|
||||
<div class="tabs-scroll">
|
||||
{#each visibleCategories as cat, idx}
|
||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={tab === String(cat.id)}
|
||||
class:tab-dragging={dragTabId === cat.id}
|
||||
draggable="true"
|
||||
onclick={() => onTabChange(String(cat.id))}
|
||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
||||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
||||
ondragleave={onTabDragLeave}
|
||||
ondrop={(e) => onTabDrop(e, cat)}
|
||||
ondragend={onTabDragEnd}
|
||||
>
|
||||
<Folder size={11} weight="bold" />
|
||||
{cat.name}
|
||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
||||
</button>
|
||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@@ -195,17 +199,19 @@
|
||||
</div>
|
||||
|
||||
<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-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.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; 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 { 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.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid transparent; }
|
||||
.tabs-anims .tab.active { background: transparent; }
|
||||
.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-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import type { StripChapter } from "../lib/scrollHandler";
|
||||
import { createPinchTracker } from "../lib/pinchZoom";
|
||||
import type { PinchTracker } from "../lib/pinchZoom";
|
||||
|
||||
interface Props {
|
||||
style: string;
|
||||
@@ -16,6 +18,9 @@
|
||||
stripToRender: StripChapter[];
|
||||
fadingOut: boolean;
|
||||
tapToToggleBar: boolean;
|
||||
pinchZoomEnabled: boolean;
|
||||
onGetZoom: () => number;
|
||||
onSetZoom: (z: number) => void;
|
||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||
onTap: (e: MouseEvent) => void;
|
||||
onWheel: (e: WheelEvent) => void;
|
||||
@@ -26,7 +31,8 @@
|
||||
const {
|
||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
|
||||
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||
}: Props = $props();
|
||||
|
||||
const INSPECT_ZOOM_STEP = 0.15;
|
||||
@@ -57,8 +63,38 @@
|
||||
let inspectPanStartX = 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) {
|
||||
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;
|
||||
inspectDragMoved = false;
|
||||
inspectDragStartX = e.clientX;
|
||||
@@ -69,6 +105,12 @@
|
||||
}
|
||||
|
||||
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 (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||
@@ -79,12 +121,48 @@
|
||||
}
|
||||
|
||||
export function onInspectMouseUp() {
|
||||
stripDragging = 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) {
|
||||
if (e.ctrlKey) { onWheel(e); return; }
|
||||
if (style === "longstrip") return;
|
||||
if (style === "longstrip") {
|
||||
if (e.ctrlKey) { onWheel(e); }
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey) { onWheel(e); return; }
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
||||
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
|
||||
@@ -107,6 +185,7 @@
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||
onTap(e);
|
||||
}
|
||||
|
||||
@@ -127,7 +206,9 @@
|
||||
onclick={handleTap}
|
||||
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
|
||||
onmousedown={onInspectMouseDown}
|
||||
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||
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" }); } }}
|
||||
>
|
||||
|
||||
@@ -189,12 +270,14 @@
|
||||
</div>
|
||||
|
||||
<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:focus { outline: none; }
|
||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||
.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; }
|
||||
|
||||
.img { display: block; user-select: none; image-rendering: auto; }
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
const lastPage = $derived(store.pageUrls.length);
|
||||
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
||||
const zoomPct = $derived(Math.round(zoom * 100));
|
||||
const pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
|
||||
|
||||
const displayChapter = $derived(
|
||||
style === "longstrip" && readerState.visibleChapterId
|
||||
@@ -479,6 +480,8 @@
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||
window.addEventListener("pointermove", pageViewRef.onPointerMove);
|
||||
window.addEventListener("pointerup", pageViewRef.onPointerUp);
|
||||
|
||||
readerState.isFullscreen = await win.isFullscreen();
|
||||
const unlistenFs = await win.onResized(async () => {
|
||||
@@ -500,6 +503,8 @@
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
||||
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
||||
cleanupScroll();
|
||||
unlistenFs();
|
||||
ro.disconnect();
|
||||
@@ -512,6 +517,7 @@
|
||||
class:overlay-bars={overlayBars}
|
||||
class:bar-left={barPosition === "left"}
|
||||
class:bar-right={barPosition === "right"}
|
||||
class:pinch-active={pinchZoomEnabled}
|
||||
role="presentation"
|
||||
onmousemove={(e) => {
|
||||
if (!tapToToggleBar) {
|
||||
@@ -580,6 +586,9 @@
|
||||
{currentGroup} {stripToRender}
|
||||
fadingOut={readerState.fadingOut}
|
||||
{tapToToggleBar}
|
||||
{pinchZoomEnabled}
|
||||
onGetZoom={() => zoom}
|
||||
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||
onTap={handleTap}
|
||||
onWheel={handleWheel}
|
||||
@@ -625,4 +634,6 @@
|
||||
|
||||
.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>
|
||||
@@ -266,6 +266,16 @@
|
||||
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
|
||||
@@ -534,6 +544,19 @@
|
||||
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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { store, addHistory, addBookmark, removeBookmark,
|
||||
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
|
||||
const AVG_MIN_PER_PAGE = 0.33;
|
||||
|
||||
@@ -30,7 +31,9 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||
if (!mangaId) return;
|
||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||
checkAndMarkCompleted(mangaId, updated);
|
||||
const ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||||
const prefs = getMangaPrefs();
|
||||
if (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs);
|
||||
if (prefs.deleteOnRead) {
|
||||
const ch = store.activeChapterList.find(c => c.id === id);
|
||||
if (ch?.isDownloaded) {
|
||||
@@ -73,4 +76,4 @@ export function toggleBookmark(
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { store, openReader } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
|
||||
export function scheduleResumeDismiss() {
|
||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||
@@ -23,6 +24,9 @@ export async function loadChapter(
|
||||
readerState.resetForChapter();
|
||||
store.pageUrls = [];
|
||||
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (mangaId) trackingState.loadForManga(mangaId);
|
||||
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 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 };
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||
clearMarkersForManga,
|
||||
} from "@store/state.svelte";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import type { MangaPrefs } from "@store/state.svelte";
|
||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "@types";
|
||||
@@ -80,18 +81,21 @@
|
||||
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
|
||||
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
|
||||
|
||||
const currentPrefs = $derived({
|
||||
sortMode,
|
||||
sortDir,
|
||||
preferredScanlator: get("preferredScanlator") as string,
|
||||
scanlatorFilter: scanlatorFilter as string[],
|
||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
||||
scanlatorForce: scanlatorForce as boolean,
|
||||
});
|
||||
|
||||
const availableScanlators = $derived(
|
||||
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const sortedChapters = $derived(buildChapterList(chapters, {
|
||||
sortMode, sortDir,
|
||||
preferredScanlator: get("preferredScanlator") as string,
|
||||
scanlatorFilter: scanlatorFilter as string[],
|
||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
||||
scanlatorForce: scanlatorForce as boolean,
|
||||
}));
|
||||
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
|
||||
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||
@@ -102,24 +106,21 @@
|
||||
|
||||
const continueChapter = $derived((() => {
|
||||
if (!sortedChapters.length) return null;
|
||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const anyRead = asc.some(c => c.isRead);
|
||||
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const anyRead = asc.some(c => c.isRead);
|
||||
const bookmark = store.activeManga
|
||||
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
||||
: null;
|
||||
if (bookmark) {
|
||||
const ch = asc.find(c => c.id === bookmark.chapterId);
|
||||
if (ch) {
|
||||
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 bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
|
||||
if (bookmarkedCh && !bookmarkedCh.isRead) {
|
||||
return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
|
||||
}
|
||||
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);
|
||||
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 };
|
||||
})());
|
||||
|
||||
@@ -160,13 +161,7 @@
|
||||
|
||||
function applyChapters(nodes: Chapter[]) {
|
||||
if (get("autoDownload") && _prevChapterIds.size > 0) {
|
||||
const filtered = buildChapterList(nodes, {
|
||||
sortMode, sortDir,
|
||||
preferredScanlator: get("preferredScanlator") as string,
|
||||
scanlatorFilter: scanlatorFilter as string[],
|
||||
scanlatorBlacklist: scanlatorBlacklist as string[],
|
||||
scanlatorForce: scanlatorForce as boolean,
|
||||
});
|
||||
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));
|
||||
}
|
||||
@@ -256,9 +251,33 @@
|
||||
}).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(() => {
|
||||
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;
|
||||
@@ -305,9 +324,21 @@
|
||||
}
|
||||
|
||||
async function markRead(chapterId: number, isRead: boolean) {
|
||||
const mangaId = store.activeManga?.id;
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
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 (get("deleteOnRead")) {
|
||||
const ch = chapters.find(c => c.id === chapterId);
|
||||
@@ -330,21 +361,34 @@
|
||||
|
||||
async function markBulk(ids: number[], isRead: boolean) {
|
||||
if (!ids.length) return;
|
||||
const mangaId = store.activeManga?.id;
|
||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||
const idSet = new Set(ids);
|
||||
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 (isRead && get("deleteOnRead")) {
|
||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||
if (toDelete.length) {
|
||||
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
||||
const doDelete = async () => {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||
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 (delayMs === 0) doDelete();
|
||||
else setTimeout(doDelete, delayMs);
|
||||
if (mangaId) {
|
||||
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);
|
||||
if (toDelete.length) {
|
||||
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
||||
const doDelete = async () => {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
|
||||
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
|
||||
if (mangaId) chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
|
||||
};
|
||||
if (delayMs === 0) doDelete();
|
||||
else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<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 Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
|
||||
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
|
||||
import { addToast } from "@store/state.svelte";
|
||||
import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking";
|
||||
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
|
||||
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 { Chapter } from "@types/index";
|
||||
|
||||
let { mangaId, mangaTitle, onClose }: {
|
||||
mangaId: number;
|
||||
@@ -16,8 +20,7 @@
|
||||
type TabId = "records" | number;
|
||||
|
||||
let trackers: Tracker[] = $state([]);
|
||||
let records: TrackRecord[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let loadingTrackers: boolean = $state(true);
|
||||
let activeTab: TabId = $state("records");
|
||||
|
||||
let searchQuery: string = $state("");
|
||||
@@ -30,26 +33,22 @@
|
||||
let syncing: number | null = $state(null);
|
||||
let editingChapter: number | null = $state(null);
|
||||
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); }
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tRes, rRes] = await Promise.all([
|
||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { 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(() => {
|
||||
loadingTrackers = true;
|
||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
.then(r => { trackers = r.trackers.nodes; })
|
||||
.catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message }))
|
||||
.finally(() => { loadingTrackers = false; });
|
||||
trackingState.loadForManga(mangaId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const tab = activeTab;
|
||||
@@ -62,7 +61,6 @@
|
||||
|
||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -96,7 +94,7 @@
|
||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||
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";
|
||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||
} catch (e: any) {
|
||||
@@ -110,7 +108,7 @@
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
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 });
|
||||
} catch (e: any) {
|
||||
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) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
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) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
@@ -139,7 +133,7 @@
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
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) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
@@ -151,7 +145,7 @@
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
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) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
@@ -162,9 +156,8 @@
|
||||
async function syncRecord(record: TrackRecord) {
|
||||
syncing = record.id;
|
||||
try {
|
||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
|
||||
patchRecord(res.fetchTrack.trackRecord);
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
const fresh = await trackingState.syncRecordFromRemote(record.id);
|
||||
if (fresh) addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
@@ -179,6 +172,33 @@
|
||||
|
||||
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) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
@@ -186,7 +206,7 @@
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
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) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
@@ -269,6 +289,11 @@
|
||||
<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" : ""} />
|
||||
</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)}>
|
||||
<X size={11} weight="bold" />
|
||||
</button>
|
||||
|
||||
@@ -226,7 +226,8 @@
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
position: absolute;
|
||||
@@ -238,12 +239,46 @@
|
||||
background: var(--text-faint);
|
||||
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);
|
||||
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 ──────────────────────────────────────────────────────── */
|
||||
.s-stepper {
|
||||
display: flex;
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
{#if tab === "general"}
|
||||
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "appearance"}
|
||||
<AppearanceSettings {onOpenThemeEditor} />
|
||||
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
|
||||
{:else if tab === "reader"}
|
||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "library"}
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
<p class="s-section-title">Links</p>
|
||||
<div class="s-section-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { Pencil, Trash, Plus } from "phosphor-svelte";
|
||||
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
|
||||
import { mountSystemThemeSync } from "@core/theme";
|
||||
import { selectPortal } from "@core/actions/selectPortal";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
closingSelect: string | null;
|
||||
toggleSelect: (id: string) => void;
|
||||
anims: boolean;
|
||||
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[] }[] = [
|
||||
{ id: "dark", label: "Dark", 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: "original", label: "Original", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
|
||||
{ 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-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: "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>
|
||||
|
||||
<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">
|
||||
<p class="s-section-title">Theme</p>
|
||||
<div class="s-theme-grid">
|
||||
|
||||
@@ -31,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>
|
||||
<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>
|
||||
{#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>
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
import { GET_TRACKERS } from "@api/queries/tracking";
|
||||
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
||||
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 trackersLoading = $state(false);
|
||||
@@ -78,6 +83,42 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="s-panel">
|
||||
@@ -148,4 +189,64 @@
|
||||
</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>
|
||||
@@ -1,193 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
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 { CircleNotch } from "phosphor-svelte";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import {
|
||||
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
|
||||
scoreToStars, calcProgress, patchTracker, removeRecord,
|
||||
type TrackerWithRecords, type FlatRecord, type SortKey,
|
||||
type FlatRecord, type SortKey,
|
||||
} from "../lib/trackingSync";
|
||||
|
||||
let trackers = $state<TrackerWithRecords[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
import TrackingToolbar from "./TrackingToolbar.svelte";
|
||||
import TrackingCard from "./TrackingCard.svelte";
|
||||
import TrackingPreview from "./TrackingPreview.svelte";
|
||||
|
||||
let activeTrackerId = $state<number | "all">("all");
|
||||
let statusFilter = $state<number | "all">("all");
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state<SortKey>("title");
|
||||
let selectedRecord = $state<FlatRecord | null>(null);
|
||||
|
||||
let updatingId = $state<number | null>(null);
|
||||
let syncingId = $state<number | null>(null);
|
||||
let editingChapter = $state<number | null>(null);
|
||||
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(trackers.filter((t) => t.isLoggedIn));
|
||||
const allRecords = $derived(flattenRecords(trackers));
|
||||
const totalCount = $derived(allRecords.length);
|
||||
$effect(() => {
|
||||
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
|
||||
trackingState.loadAll();
|
||||
}
|
||||
});
|
||||
|
||||
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
|
||||
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
|
||||
const totalCount = $derived(allRecords.length);
|
||||
const statusOptions = $derived(
|
||||
activeTrackerId === "all"
|
||||
? dedupeStatuses(trackers)
|
||||
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
|
||||
? dedupeStatuses(trackingState.allTrackers)
|
||||
: loggedIn.find(t => t.id === activeTrackerId)?.statuses ?? []
|
||||
);
|
||||
|
||||
const filtered = $derived(
|
||||
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>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="heading">Tracking</h1>
|
||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh">
|
||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !loading && loggedIn.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
class="tracker-tab" class:active={activeTrackerId === "all"}
|
||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||
>
|
||||
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>
|
||||
<TrackingToolbar
|
||||
{loggedIn}
|
||||
{totalCount}
|
||||
{activeTrackerId}
|
||||
{statusFilter}
|
||||
{statusOptions}
|
||||
{searchQuery}
|
||||
{sortBy}
|
||||
loading={trackingState.loadingAll}
|
||||
onRefresh={() => trackingState.loadAll()}
|
||||
onTrackerChange={(id) => { activeTrackerId = id; statusFilter = "all"; }}
|
||||
onStatusChange={(v) => statusFilter = v}
|
||||
onSearchChange={(v) => searchQuery = v}
|
||||
onSortChange={(v) => sortBy = v}
|
||||
/>
|
||||
|
||||
<div class="body">
|
||||
{#if loading}
|
||||
{#if trackingState.loadingAll}
|
||||
<div class="state">
|
||||
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
{:else if trackingState.error}
|
||||
<div class="state">
|
||||
<span class="state-error">{error}</span>
|
||||
<button class="ghost-btn" onclick={load}>Retry</button>
|
||||
<span class="state-error">{trackingState.error}</span>
|
||||
<button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
|
||||
</div>
|
||||
|
||||
{:else if loggedIn.length === 0}
|
||||
@@ -207,240 +81,28 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||
{@const isBusy = updatingId === record.id}
|
||||
{@const isSyncing = syncingId === record.id}
|
||||
{@const progress = calcProgress(record.lastChapterRead, record.totalChapters)}
|
||||
{@const stars = scoreToStars(record.displayScore, record.tracker.scores)}
|
||||
|
||||
<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>
|
||||
<TrackingCard
|
||||
{record}
|
||||
active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
|
||||
onSelect={(r) => selectedRecord = r}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if confirmUnbind}
|
||||
{@const r = confirmUnbind}
|
||||
<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 selectedRecord}
|
||||
<TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
|
||||
{/if}
|
||||
|
||||
<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); }
|
||||
.header-top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-3);
|
||||
.body {
|
||||
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
.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 {
|
||||
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-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); }
|
||||
|
||||
.ghost-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 14px; border-radius: var(--radius-md);
|
||||
@@ -462,206 +125,4 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
|
||||
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 { 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 {
|
||||
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>
|
||||
.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; }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { probeServer, loginBasic } from "@core/auth";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
|
||||
const MAX_ATTEMPTS = 40;
|
||||
|
||||
@@ -33,6 +34,7 @@ export function startProbe() {
|
||||
if (result === "ok") {
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ export function startProbe() {
|
||||
try {
|
||||
await loginBasic(savedUser, savedPass);
|
||||
boot.loginRequired = false;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
@@ -82,6 +85,7 @@ export async function submitLogin(onSuccess: () => void) {
|
||||
boot.loginRequired = false;
|
||||
boot.loginPass = "";
|
||||
boot.loginError = null;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
boot.loginError = e?.message ?? "Login failed";
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Manga, Chapter } from "@types";
|
||||
const APP_ID = "1487894643613106298";
|
||||
const FALLBACK_IMAGE = "moku_logo";
|
||||
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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,193 +1,29 @@
|
||||
import type { Manga, Chapter, Category, Source } from "../types";
|
||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../core/keybinds/defaultBinds";
|
||||
import { notifications } from "./notifications.svelte";
|
||||
import { app } from "./app.svelte";
|
||||
import type { Manga, Chapter, Category, Source } from "../types";
|
||||
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 { app } from "./app.svelte";
|
||||
|
||||
export type { NavPage } from "./app.svelte";
|
||||
export type { Toast, ActiveDownload } from "./notifications.svelte";
|
||||
export type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
|
||||
LibraryFilter, LibrarySortMode, LibrarySortDir,
|
||||
LibraryStatusFilter, LibraryContentFilter,
|
||||
PageStyle, FitMode, ReadingDirection,
|
||||
ChapterSortDir, ChapterSortMode,
|
||||
BuiltinTheme, Theme, ThemeTokens,
|
||||
MangaPrefs } from "../types/settings";
|
||||
export { DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS,
|
||||
DEFAULT_THEME_TOKENS } from "../types/settings";
|
||||
export type { HistoryEntry, BookmarkEntry, MarkerEntry, MarkerColor,
|
||||
ReadLogEntry, ReadingStats, LibraryUpdateEntry } from "../types/history";
|
||||
|
||||
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 = "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[];
|
||||
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";
|
||||
}
|
||||
|
||||
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,
|
||||
pinnedSourceIds: [],
|
||||
readerPresets: [],
|
||||
mangaReaderSettings: {},
|
||||
};
|
||||
|
||||
const STORE_VERSION = 3;
|
||||
const STORE_VERSION = 3;
|
||||
const AVG_MIN_PER_CHAPTER = 5;
|
||||
const RESET_ON_UPGRADE: (keyof Settings)[] = ["serverBinary", "readerZoom", "uiZoom"];
|
||||
|
||||
@@ -229,38 +65,38 @@ function mergeSettings(saved: any): Settings {
|
||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
|
||||
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
|
||||
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||
extraScanDirs: saved?.settings?.extraScanDirs ?? [],
|
||||
pinnedSourceIds: saved?.settings?.pinnedSourceIds ?? [],
|
||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
class Store {
|
||||
settings: Settings = $state(mergeSettings(saved));
|
||||
activeManga: Manga | null = $state(null);
|
||||
previewManga: Manga | null = $state(null);
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
pageUrls: string[] = $state([]);
|
||||
pageNumber: number = $state(1);
|
||||
libraryFilter: LibraryFilter = $state("all");
|
||||
categories: Category[] = $state([]);
|
||||
activeSource: Source | null = $state(null);
|
||||
libraryTagFilter: string[] = $state([]);
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
bookmarks: BookmarkEntry[]= $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
|
||||
searchCache: Map<string, any> = $state(new Map());
|
||||
searchLibraryIds: Set<number> = $state(new Set());
|
||||
searchSrcOffset: number = $state(0);
|
||||
readerSessionId: number = $state(0);
|
||||
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
|
||||
settings: Settings = $state(mergeSettings(saved));
|
||||
activeManga: Manga | null = $state(null);
|
||||
previewManga: Manga | null = $state(null);
|
||||
activeChapter: Chapter | null = $state(null);
|
||||
activeChapterList: Chapter[] = $state([]);
|
||||
pageUrls: string[] = $state([]);
|
||||
pageNumber: number = $state(1);
|
||||
libraryFilter: LibraryFilter = $state("all");
|
||||
categories: Category[] = $state([]);
|
||||
activeSource: Source | null = $state(null);
|
||||
libraryTagFilter: string[] = $state([]);
|
||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
bookmarks: BookmarkEntry[]= $state(saved?.bookmarks ?? []);
|
||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
|
||||
searchCache: Map<string, any> = $state(new Map());
|
||||
searchLibraryIds: Set<number> = $state(new Set());
|
||||
searchSrcOffset: number = $state(0);
|
||||
readerSessionId: number = $state(0);
|
||||
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||
acknowledgedUpdates: Set<number> = $state(new Set(saved?.acknowledgedUpdateIds ?? []));
|
||||
|
||||
get toasts() { return notifications.toasts; }
|
||||
get activeDownloads() { return notifications.activeDownloads; }
|
||||
|
||||
@@ -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 "./tracking";
|
||||
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,
|
||||
};
|
||||